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
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.