UX Icons JS showcase

A live demo of silarhi/ux-icons-js-bundle and its npm companion @silarhi/ux-icons-js: the Symfony UX Icons of your Twig templates, in JavaScript, React and Vue. Everything is documented on ux-icons-js-bundle.silarhi.dev. Why and how it was built: the introduction on the SILARHI blog (in French).

On cache warmup, the bundle dumps the local icons of assets/icons/ into var/icons/, one named export per icon, with the attributes ux_icon() gives them. The JavaScript side renders them without innerHTML. Switch any example to Code to see the code behind it.

Same HTML as ux_icon()

The same icon with the same attributes, rendered three times: by Twig on the server, by vanilla JavaScript and by React in your browser.

{{ ux_icon('lucide:trash-2', {
    class: 'text-danger',
    'aria-label': 'Delete',
}) }}
import { createIconElement } from '@silarhi/ux-icons-js'
import { LucideTrash2Icon } from '@icons/index.js'

element.append(createIconElement(LucideTrash2Icon, {
    class: 'text-danger',
    'aria-label': 'Delete',
}))
import { UXIcon } from '@silarhi/ux-icons-js/react'
import { LucideTrash2Icon } from '@icons/index.js'

<UXIcon
    icon={LucideTrash2Icon}
    className="text-danger"
    aria-label="Delete"
/>

Not just the same look: the same markup. renderIcon() returns the icon as an HTML string, compared below with the one ux_icon() rendered on the server.

The two outputs

Comparing…

Twig, ux_icon(), on the server

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" height="1em" width="1em" class="text-danger" aria-label="Delete"><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 11v6m4-6v6m5-11v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>

JavaScript, renderIcon(), in your browser

The ux_icons configuration, reused

Nothing to repeat for JavaScript: the bundle reads the ux_icons configuration and dumps each icon with its attributes already resolved. Here, width, height and fill come from default_icon_attributes, the icon-lucide class from the Lucide icon set, and the delete alias adds a DeleteIcon export.

config/packages/ux_icons.yaml

ux_icons:
    default_icon_attributes:
        fill: currentColor
        height: '1em'
        width: '1em'

    icon_sets:
        lucide:
            icon_attributes:
                class: 'icon-lucide'

    aliases:
        delete: 'lucide:trash-2'

var/icons/index.js, as dumped

export const LucideTrash2Icon = {"name":"lucide:trash-2","attributes":{"xmlns":"http://www.w3.org/2000/svg","viewBox":"0 0 24 24","fill":"currentColor","height":"1em","width":"1em","class":"icon-lucide"},"children":[["path",{"fill":"none","stroke":"currentColor","stroke-linecap":"round","stroke-linejoin":"round","stroke-width":"2","d":"M10 11v6m4-6v6m5-11v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"}]]};

Vanilla JavaScript in a Stimulus controller

A copy button: Twig renders its icon with ux_icon(), and the Stimulus controller swaps it for a check mark once the command is copied. createIconElement() builds the <svg> with the DOM API: no HTML is parsed, which suits a strict Content Security Policy.

import { Controller } from '@hotwired/stimulus'
import { createIconElement } from '@silarhi/ux-icons-js'
import { LucideCheckIcon, LucideCopyIcon } from '@icons/index.js'

export default class extends Controller {
    static targets = ['icon']
    static values = { text: String }

    async copy() {
        await navigator.clipboard.writeText(this.textValue)

        this.iconTarget.replaceChildren(createIconElement(LucideCheckIcon, { class: 'text-success' }))
        setTimeout(() => this.iconTarget.replaceChildren(createIconElement(LucideCopyIcon)), 2000)
    }
}

React component

UXIcon renders the SVG body as React elements, without dangerouslySetInnerHTML. Its props follow the ux_icon() rules, event handlers and refs are passed to React as they are. Only the imported star ends up in the bundle: the other icons of the dump are tree-shaken.

import { UXIcon } from '@silarhi/ux-icons-js/react'
import { useState } from 'react'
import { LucideStarIcon } from '@icons/index.js'

export default function Rating({ max = 5 }) {
    const [rating, setRating] = useState(3)

    return Array.from({ length: max }, (_, index) => (
        <button
            key={index}
            type="button"
            aria-label={`Rate ${index + 1} out of ${max}`}
            onClick={() => setRating(index + 1)}
        >
            <UXIcon icon={LucideStarIcon} className={index < rating ? 'text-yellow' : 'text-muted'} />
        </button>
    ))
}

Icon names from an API

Named imports need the icon name when you write the code. Here, the names come from a JSON API: UXLazyIcon loads each icon by its name, alone, from the dumped loaders map, and shows a fallback meanwhile. Open the network tab of your dev tools: every icon is its own small chunk. The last name is unknown to the dump, so its fallback stays.

import { UXLazyIcon } from '@silarhi/ux-icons-js/react'
import { loaders } from '@icons/loaders.js'

// [{ "label": "Web servers", "icon": "lucide:server", "status": "Operational", ... }, ...]
const services = await (await fetch('/icons/api/status.json')).json()

services.map(({ label, icon }) => (
    <li key={label}>
        <UXLazyIcon name={icon} loaders={loaders} fallback={<span className="placeholder" />} />
        {label}
    </li>
))

// Without React: const icon = await loadIcon(loaders, name) // null for an unknown name

Attributes, the ux_icon() way

The attributes given in JavaScript are merged with the dumped ones following the ux_icon() rules: aria-hidden="true" unless the icon has a text alternative, token lists for class, null to remove an attribute, JSON for data-*. The right column is computed by resolveIcon() in your browser (xmlns and viewBox left out).

Attributes given to LucideHeartIcon Resulting <svg> attributes

A new icon in two steps

Write the import first, then run ux:icons:lock: the bundle finds the icons imported in your JavaScript files, symfony/ux-icons downloads them from Iconify, and they are dumped again.

1. Import it

// assets/js/controllers/mail_controller.js
import { LucideMailIcon } from '@icons/index.js'

2. Lock it

$ php bin/console ux:icons:lock
 ✓ Imported lucide:mail (License: ISC). Render with: {{ ux_icon('lucide:mail') }}
 JavaScript: import { LucideMailIcon }
 // Dumped 16 icon(s) for JavaScript.