At n8n icons, along with emojis, are used to help identify projects, workflows and agents.
The N8nIconPicker component provides one place to search, preview and select them. It supports a range of icons from Lucide as well as emojis, colour choices, skin tones. However, after using it a lot, a few problems surfaced. The popover was cramped. It didn’t support any key events, so navigating quickly was impossible. It was also over-complex and hard to maintain.
I worked on improving its layout, performance and keyboard support. This post covers three parts of that work: replacing broken virtualisation with progressive rendering, adding X/Y key navigation, and managing performance for thousands of DOM nodes.
I worked on improving the component in three places:
- Introducing ‘progressive rendering’, replacing the current virtualisation approach
- Add key events such as navigating up/down/left/right
- Tidy up the visual design
You can see the code that shipped for this refactor here
Progressive rendering
The picker can contain more than 1,000 options.
In the previous version, the component loaded all these items when the component mounted and rendered the visible items through a virtualised list.
Virtualisation reduces the number of elements in the DOM. It is useful for large or complex lists, especially when people scroll through them for long periods.
This is a valid approach, but it has trade-offs. The biggest trade-off with virutalisation is managing it. It includes managing external dependencies, guessing row heights and a bunch of other stuff which can be a pain. In particular the current n8n pattern for virtualisation was pretty ineffective, so we needed something different.
Instead, a simple progressive rendering will do fine. I also moved data loading to a hover event on the trigger so that we only load the items when there is clear intent (more below).
Rendering is contained in a composable, so the component decides which rows to display. It renders the first 10 rows, then observes a marker at the end of the grid. When that marker approaches the visible scroll area, the composable adds the next 10 rows.
When the popover mounts, the picker adds 10 rows. It does no more rendering until the user scrolls towards the end of that set. A small margin starts the next batch before the marker enters view, which prevents an empty gap during normal scrolling.
This approach keeps the first render small without changing the grid structure. It also avoids filling the browser’s main thread with work on every animation frame. Search still operates on the complete data set, while the DOM only contains the rows needed for the current scroll position.
The observer disconnects when its marker changes or the component unmounts. This prevents old observers from continuing after the active tab, search results or parent component changes.
Keyboard navigation
The main method of finding the icon/emoji you want is search. Naturally we auto-focus the user when the popover mounts.
Often you may search for smile and have multiple options to choose from. Using keys to navigate to the item you like is much faster and natural than jumping back to your mouse. The existing version didn’t support any key events.
Whilst keeping the search input focused, the picker tracks an active grid coordinate in Vue state. Arrow keys change that coordinate. Enter selects the active option.
Because we want to move across X and Y, we should work with coordinates rather than a flat item index like you might for a vertical-only list. Each movement needs to also skip headings and clamp the column to the number of items in the next row.
A trade-off we’re making here is faking a focus state on the active icon/emoji. This can easily confuse screen-readers because as far as they are concerned the search input is what is in focus, not the active item.
Using aria-activedescendant to identify the active option while retaining focus on the input gives screen-readers a clear indication of which item is active.
Loading data
The existing component loaded both icon and emoji data on mount.
So regardless of if the user intends to use the component, we hold both entire sets of data in memory.
It’s a simple performance gain to move the loading to fire on a hover event. This intent-based loading allows us to be efficent with what data we hold in memory. If the user has no intention to use the picker, then they don’t need the data.
Also we only load whatever tab is visible by default. For example, if the user opens the picker to the Icons tab - only the icons are loaded. Emojis are then loaded, again by an hover event when the user hovers over the Emojis tab.
This creates a useful sequence:
- Prefetch the likely data after pointer intent.
- Render a small first set of rows when the popover opens.
- Add more rows as the user approaches the end of the rendered grid.
- Keep search and keyboard navigation connected to the complete data set.
Custom tooltips
Every option needs a readable name, but mounting more than 1,000 tooltip components creates serious lag. Each tooltip component wraps its own listener and control for hiding, positioning, and showing the tooltip. It locked up my browser immediately.
Instead, the picker renders one shared tooltip. Pointer and keyboard events update its label and position for the current option. A short delay prevents a trail of tooltips when the pointer moves across the grid.
This keeps the option markup small and gives icons and emojis the same naming behaviour. It also makes the readable label available to pointer, keyboard and assistive technology users.
Conclusion
The goal was taking a component that we already use and giving it a nice tune-up.
I really enjoy this kind of ‘MOT’ servicing of components because once you’ve done a few you can find easy wins in most component you use. Many of them can be small gains, but sometimes you stumble on a substantial improvement which improves the user experience.
The new picker opens with useful content at once, supports two-dimensional keyboard movement, and keeps search focused throughout selection. Its wider layout gives each control a clear place, while conditional and lazy loading keep the component suitable for the many parts of n8n that use it.
FYI: This post uses a mix of AI and human-written content.