Drupal: building your own SVG icon pack with the Icon API and UI Icons

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_library
  • ui_icons_field: an "icon" field type, which stores the reference as pack_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/*.svg

The 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 single pack_id:icon_id reference 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", without stroke-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.1

A 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 svg extractor exposes the attributes of the SVG root element in attributes, so we reuse attributes.viewBox with a fallback to 0 0 24 24. This is what lets non-square icons keep their proportions without any extra setting;
  • aria-hidden="true" and focusable="false": our icons are decorative, their accessible name always comes from the text or the aria-label that accompanies them. focusable="false" removes them from the tab order on engines that still put SVGs in it;
  • stroke-width set 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}.svg

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

Written by Kevin Gautreau — freelance PHP/Drupal developer & trainer in Auvergne, France. get in touch

Related content

article · 6 Aug 2026 Drupal: adding icons to your custom menu links in the admin navigation The new core Navigation module (the vertical sidebar that replaces the old horizontal toolbar) displays an icon in front of each top-level entry. For core… article · 29 May 2026 Uzinasit: lessons learned from my Drupal 11 site factory I took advantage of having a bit of free time during summer 2025 to start rationalizing all my websites, to make them easier to manage Why a site factory?…

Comments (1)

Comments

nd (not verified)

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:

  • Sous Windows, il faut appliquer le patch du 2ème merge request du bug signalé ici : https://www.drupal.org/project/drupal/issues/3579927
  • j'ai du mettre les sources d'iconoir directement dans mon thème car Drupal n'arrivait pas à refaire les chemins si je mettais les sources dans libraries. D'ailleurs, pour récupérer les sources, la commande npm est "npm i iconoir"
  • Puis j'ai utilisé la conf ci-dessous pour mon_theme.icons.yml

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 %}