Since Drupal 11.1, core ships with an Icon API: you declare an icon pack in a YAML file, and a Twig function icon() renders the icons anywhere (templates, SDC, render arrays). On the back-office side, the contrib module UI Icons adds what core lacks: a field type, a visual picker and a page listing all installed packs.
You can use the usual icon kits with UI Icons, but you can also build your own custom kit from SVG files. That's what we'll cover here, with a custom kit and the integration of the open source library Iconoir.
The modules
Let's start by installing UI Icons and enabling its submodules:
composer require drupal/ui_icons
drush en ui_icons ui_icons_field ui_icons_picker ui_icons_libraryui_icons_field: an "icon" field type, which stores the reference aspack_id:icon_id;ui_icons_picker: the visual picker in forms (a modal with search, much nicer than a select);ui_icons_library: an inventory page under/admin/appearance/ui/icons, handy to check that a pack is properly discovered and to show the catalog to the client.
Declaring the pack in the theme
A pack is declared in an <extension>.icons.yml file, at the root of a module or a theme.
In web/themes/custom/mon_theme/mon_theme.icons.yml:
mon_pack:
enabled: true
label: 'Mes icônes'
description: 'Icônes SVG du projet.'
version: '1.0.0'
license:
name: 'Proprietary'
url: 'https://exemple.fr'
gpl-compatible: false
extractor: svg
config:
sources:
- icons/*.svgThe SVG files are simply dropped into web/themes/custom/mon_theme/icons/, and the file name becomes the icon ID: toque.svg gives mon_pack:toque.
Two things to know:
- the pack ID is the YAML key (
mon_pack), not the name of the extension that holds it. So you can move the declaration from a module to a theme (or the other way around) without breaking a singlepack_id:icon_idreference stored in the database. - packs are cached aggressively: after adding an icon or changing the YAML, run
drush cr. But if you've been doing Drupal for a while, you're used to it.
Normalizing the SVG sources
The SVG files delivered for our project weren't exactly great... The rule: no hardcoded color or stroke width in the sources. Painting is handled by currentColor attributes on the paths:
- filled icons:
fill="currentColor"; - outline icons:
fill="none" stroke="currentColor", withoutstroke-width(the template will set it).
<!-- Normalized outline icon -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M13.145 7.907c..." fill="none" stroke="currentColor"
stroke-linecap="round" stroke-linejoin="round"/>
</svg>
<!-- Normalized filled icon -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M20.863 7.782c..." fill="currentColor"/>
</svg>Thanks to this, the icon inherits the surrounding text color by default, and the pack's color and stroke settings apply to every single icon. A raw Illustrator or Figma export never follows these rules: you have to go over each delivered file once, when it enters the pack.
On the other hand, we keep each file's original viewBox, including for non-square icons (our wave is 53.761 × 41.711). The template will use it.
Settings: size, color, stroke
A pack can declare settings, described in JSON Schema. UI Icons turns them into a small form in the picker and the field type, and the values reach the template as Twig variables:
settings:
size:
title: 'Size'
description: 'Largeur et hauteur en pixels. Sans valeur, le CSS du contexte décide.'
type: 'integer'
color:
title: 'Color'
description: "Couleur de l'icône. Défaut : couleur du texte."
type: 'string'
format: 'color'
stroke:
title: 'Stroke width'
description: 'Épaisseur du trait des icônes filaires.'
type: 'number'
default: 2
minimum: 1
maximum: 3
multipleOf: 0.1A deliberate choice here: size has no default value. Our components size their icons in CSS (em, variables, media queries), so a hardcoded width/height would only cause headaches.
The template
Each pack provides its own rendering template, directly in the YAML. It receives content (the content extracted from the SVG), attributes, icon_id and the settings:
template: >-
{% if color %}<span style="color: {{ color }};">{% endif %}
<svg{{
attributes
.setAttribute('xmlns', 'http://www.w3.org/2000/svg')
.setAttribute('viewBox', attributes.viewBox|default('0 0 24 24'))
.setAttribute('stroke-width', stroke|default(2))
.setAttribute('aria-hidden', 'true')
.setAttribute('focusable', 'false')
.addClass('icon', 'icon--' ~ icon_id|clean_class)
}}{% if size %} width="{{ size }}" height="{{ size }}"{% endif %}>{{ content }}</svg>
{% if color %}</span>{% endif %}Line by line, the points that matter:
- the viewBox comes from the source file: the
svgextractor exposes the attributes of the SVG root element inattributes, so we reuseattributes.viewBoxwith a fallback to0 0 24 24. This is what lets non-square icons keep their proportions without any extra setting; aria-hidden="true"andfocusable="false": our icons are decorative, their accessible name always comes from the text or thearia-labelthat accompanies them.focusable="false"removes them from the tab order on engines that still put SVGs in it;stroke-widthset on the root: since the sources don't carry any, a single value drives all outline icons, and has no effect on filled ones;- the
icon--<id>class, in case you want to add CSS styling on a case-by-case basis.
Usage
Anywhere in Twig, through the core function:
{{ icon('mon_pack', 'poele') }}
{{ icon('mon_pack', 'arrow', { size: 32, color: '#2e5941' }) }}In PHP, through a render array:
$build['icon'] = [
'#type' => 'icon',
'#pack_id' => 'mon_pack',
'#icon_id' => 'poele',
'#settings' => ['size' => 32],
];And on the content side, the ui_icons_field field stores the value as pack_id:icon_id. In an SDC component that receives this value through a prop, you can split it:
{% set icon_parts = icon|split(':') %}
{{ icon(icon_parts[0], icon_parts[1]) }}Editors pick their icon in the picker, with preview and search, and can choose from either the custom pack or Iconoir: packs add up.
Installing and using a third-party library: Iconoir
The same mechanism can expose a library installed through npm, without a dedicated module. Sources accept patterns with the {group} and {icon_id} placeholders:
iconoir:
enabled: true
label: 'Iconoir'
version: 7.11.0
license:
name: MIT
url: https://github.com/iconoir-icons/iconoir/blob/main/LICENSE
gpl-compatible: true
extractor: svg
config:
sources:
- /libraries/node_modules/iconoir/icons/{group}/{icon_id}.svgThe absolute path starts from the docroot (in the current project, front-end npm packages are installed in web/libraries/, which is not versioned).
For my uzinasit infrastructure, I created submodules for Iconoir, Font Awesome... so that one or more packs can be enabled directly, per site. I've made a note to share the code here at some point.
Comments (1)
Comments
Bonjour, super article mais de mon côté j'ai rencontré pas mal d'embuche pour que ca fonctionne sur mon site de test (sous Wamp). Donc voici quelques compléments:
iconoir:enabled: true
label: "Iconoir"
description: "Iconoir is an open-source library with 1500+ unique SVG icons, designed on a 24x24 pixels grid."
links:
- https://github.com/iconoir-icons/iconoir
version: 7.12.1
license:
name: MIT
url: https://github.com/iconoir-icons/iconoir/blob/main/LICENSE
gpl-compatible: true
extractor: svg
config:
sources:
- iconoir/icons/{group}/{icon_id}.svg
settings:
size:
title: "Size"
type: "integer"
default: 32
color:
title: "Color"
type: "string"
format: "color"
stroke:
title: "Stroke width"
type: "number"
default: 1.5
minimum: 1.0
maximum: 2.0
multipleOf: 0.1
class:
title: "Class"
description: "Classe(s) CSS ajoutée(s) au SVG — le core Navigation s'en sert (toolbar-button__icon)."
type: "string"
template: >-
{% if color is defined %}
{% endif %}
{{ content }}
{% if color is defined %}
{% endif %}