Icon Picker
A searchable icon grid in a popover — the pickable set is a name → component map you supply, paged into bounded windows, with combobox keyboard navigation and recents.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/icon-picker.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "IconPicker" component: a trigger showing
the current icon and its name, and a popover holding a search field, a recents
row, a paged icon grid and a pager. Use the Radix Popover primitive (portal,
collision-aware placement, dismiss-on-outside, focus return) and lucide-react
ONLY for the component's own chrome — search, clear, pager arrows, the trigger's
caret and the empty-selection placeholder. Merge classNames with cn().
Contract
- export type IconPickerIconComponent = React.ComponentType<{ className?: string }>.
The narrowest useful shape: every lucide export satisfies it, and so does a
hand-written SVG function component. Because the type promises only className,
the component must never pass aria-hidden (or any other prop) to a consumer
icon; it wraps the glyph in an aria-hidden span instead.
- export type IconPickerIconMap = Record<string, IconPickerIconComponent>. This is
the pickable set and it is REQUIRED: the component ships no icons of its own, so
the bundle cost is exactly what the consumer hands it. The keys ARE the values —
whatever onValueChange reports is a key of this map — and key order is display
order.
- export default + a named forwardRef component. The ref lands on the root
wrapper, which holds the trigger, the clear button and a live region. Props
extend HTMLAttributes<HTMLDivElement> minus defaultValue:
- icons (required).
- value?: string | null, defaultValue?: string | null, onValueChange?: (name:
string | null) => void. null means "nothing chosen"; undefined means
"uncontrolled". A value that is not a key of the map still renders — the
trigger shows the placeholder glyph next to the raw name rather than blanking
a field the consumer's database says is set.
- keywords?: Record<string, string[]> — synonyms per icon name (other
languages, product words). Searched alongside the name; never shown.
- recent?: string[] / onRecentChange?: (recent: string[]) => void — the
"Recently used" row. Uncontrolled by default, so the row works out of the box
and persistence stays the consumer's problem.
- columns? (default 8, clamped 2-12), rows? (default 6, clamped 1-12).
columns * rows is the page size; columns also caps the recents row and sets
the panel width.
- open? / onOpenChange?, defaultQuery? (applied on EVERY open, not just the
first), placeholder?, searchPlaceholder?, clearable? (default true),
disabled? (default false), label? (default "Icon").
- value, open and recent each run through one controlled-or-uncontrolled hook
that keeps the change callback in a ref, so an inline arrow function never
invalidates the setter identity.
Behavior
- The set is normalised once per identity change into { name, Icon, haystack },
where haystack is the lowercased raw name plus a de-camel-cased copy plus the
keywords: that is what makes "arrow up" find ArrowUpRight and "arrowup" find it
too. Document that `icons` and `keywords` should be module constants or
memoised — their identity is the memo key.
- Search: every whitespace-separated term must be a substring of the haystack, so
extra words narrow instead of widening. The scan is a plain substring test over
a pre-built string; at ~1,500 icons it is far cheaper than a re-render, so there
is no debounce, no deferred value and no timer to clean up.
- Paging instead of virtualisation. filtered.slice(page * size, +size) is the only
windowing there is: at most columns * rows + columns cells exist at once whether
the map holds 12 icons or 1,500. There is no scroll container, no measurement
pass, and no windowing arithmetic that can disagree with the layout. The grid
keeps a reserved min-height of rows * CELL, so a short final page never resizes
the panel.
- The recents row is rendered only while the search box is empty (during a search
it would duplicate results and shift every index). It is deduped, capped at
`columns`, and names the current set no longer knows are skipped rather than
drawn as holes.
- Navigation model: the visible cells are one flat list, recents first, then the
page. ArrowRight/Left step by one — which is what makes the walk wrap at the end
of a row — and stepping off either end of the page turns to the previous/next
page, landing on its last/first cell. ArrowDown/Up step by `columns`; from the
recents row Down enters the grid in the same column, and from the grid's first
row Up returns to the recents row (or, with no recents, to the same column of
the previous page's last row). ArrowDown on the last row of the last page falls
to the final cell instead of being a dead key, so a partial bottom row is
reachable. PageDown/PageUp turn pages. Enter picks the highlighted cell (and
does nothing, without preventDefault, when there is nothing to pick, so a
wrapping form still sees the key). Escape closes the popover and Radix returns
focus to the trigger.
- Home/End are deliberately NOT hijacked: the focused element is a text field and
moving the caret is what a reader means by those keys.
- Selecting: fires onValueChange with the key, pushes the name onto the front of
the deduped recents list (capped at `columns`), announces the pick and closes
the popover. Clearing sets null, announces it, and moves focus to the trigger
FIRST — the clear button unmounts with the value it clears, and the browser
would otherwise drop focus on the body.
- Opening resets the panel: query goes back to defaultQuery, the pointer preview
is dropped, and if the current value is in the filtered set the picker jumps to
the page holding it and highlights it — nobody should have to hunt for the value
they already have. That landing has to survive the render in which the list is
rebuilt, so it travels as state, not as a ref written during render (a ref
written in render is consumed twice under StrictMode's double invocation and the
second pass loses it).
- disabled uses aria-disabled plus guards in the handlers (the open request, the
pick and the clear), never the native attribute: the browser blurs a control the
instant it becomes disabled, and this field can be locked while the reader is
standing on it. Same for the two pager arrows, which go in and out of range as
the query changes.
- The panel animates on enter only. An exit animation would keep it mounted while
a pick is already rewriting the recents row and resetting the page, and the
reader would watch the list rearrange as it fades.
- Cleanup: the only timer is the one that empties the live region ~2s after an
announcement (clearing it is what lets the next identical message be announced
again); it is cleared on unmount and before each new announcement.
Rendering & styling
- Semantic tokens only: bg-popover / text-popover-foreground for the panel,
bg-background + border for the trigger, bg-accent for hover and for the
highlighted cell, ring-ring for focus-visible and for the highlight, ring-primary
+ text-primary for the cell that is the current value, text-muted-foreground for
chrome and the empty state. No hex, no rgb, no arbitrary colours.
- ARIA is the deliverable here:
- The search field is role="combobox" with aria-expanded, aria-controls pointing
at the grid and aria-activedescendant naming the highlighted cell. DOM focus
NEVER leaves that field, which is what makes "type, arrow, Enter" one gesture
and what guarantees that a cell paged out from under the reader cannot take
focus with it.
- The grid is role="listbox" containing one role="group" per section (recents,
results), each labelled by its visible header, each wrapped in a
role="presentation" div — a role-less wrapper would hide the group from the
listbox.
- Each cell is role="option" with the icon name as aria-label and the glyph
aria-hidden. aria-selected marks the HIGHLIGHTED option, as the combobox
pattern expects; the icon actually in use carries aria-current="true", so the
two states never overwrite each other.
- The empty state is a paragraph with role="presentation" (a listbox may only
own groups and options) and it quotes the query back: No icons match "zzz".
- Two polite live regions: one inside the panel reporting the match count and
page while a search is active (empty otherwise, so the first query counts as a
change), one on the root reporting selection and clearing.
- Because aria-activedescendant moves no DOM focus, the component is responsible
for scrolling the highlighted cell into view (block: "nearest"); it only ever
does anything when the viewport is too short for `rows` rows.
- The panel width is columns * CELL + padding + border, capped at
calc(100vw - 2rem), with max-height from Radix's available-height variable.
Customization levers
- Density and scale: `columns` and `rows` are the two dials — they set the page
size, the panel width and how often the pager appears. CELL (40px) is a constant
the width maths uses; change it together with the cell's h-10 class.
- The set: pass a curated 60-icon object for a settings field, a 1,500-icon module
for a design tool, or your own SVG components for a brand mark picker. Sort or
group by ordering the keys. To offer categories instead of recents, keep one map
per category and swap the `icons` prop from a tab bar above the field — the
panel resets its page and highlight whenever the set changes.
- Search quality lives in `keywords`, not in the matcher: map product words and
other languages onto icon names there.
- Which sub-blocks exist: the recents row (omit `recent` and never call
onRecentChange to drop it), the preview name in the footer, the counter in the
section header and the pager are independent siblings; deleting any of them
costs nothing else.
- Behaviour dials: `defaultQuery` opens the panel pre-filtered (useful for a
domain-scoped field), `clearable` removes the clear button when the field is
required, `label` renames the field for screen readers and for the clear
button's own label.
- Persistence: wrap recent / onRecentChange in your own store. If that store is
localStorage, read it through useSyncExternalStore or an effect (a render-time
read desynchronises hydration) and wrap the write in try/catch — Safari private
mode and a full quota both throw on setItem.Concepts
- Paged window — the only windowing is
filtered.slice(page * size, + size), so at most one page of cells plus the one-row recents strip exists at once, whether the set holds twelve icons or fifteen hundred. Because a page is exactlycolumns × rows, the grid's height is arithmetic: there is no scroll container to measure, nothing to keep in sync, and a short final page reserves its height rather than resizing the panel. - Activedescendant, not roving focus — DOM focus stays in the search field for the whole session; the highlighted cell is named by
aria-activedescendant. That is what lets one keystroke filter and the next one move, and it is why a cell that pages out from under the reader can never take focus with it and strand them on<body>. - One flat order across two sections — recents and the current page are a single index space, so "next cell" needs no special case at a row end, at the boundary between recents and the grid, or at the edge of a page; only the page turn adds a rule, and it keeps the column so a vertical walk stays vertical.
- The set is a prop — the component imports icons only for its own chrome. Whatever map the consumer passes defines the values, the display order and the whole bundle cost, which is what makes the same component work for a lucide subset, the full lucide set and a hand-drawn brand set.
- Consumer-owned recents — the panel derives the new list and hands it back; where it is stored and whether it survives a reload stays outside the component, which is why nothing here reads a browser API during render.
- Refusals are named — a search that matches nothing keeps the grid's height, quotes the query back in the empty state, reads "0 of N" in the counter and leaves both pager arrows aria-disabled rather than removing them, so nothing moves under a reader who is standing on one.
Input Group
A composable affix shell for one field — static text, icon slots and interactive trailing controls flush inside a single border, with size, invalid and disabled flowing down to every slot.
Multi Select
A multi-selection field: removable chips with a +N collapse in the trigger, a searchable checkbox listbox, select-all / clear, group headings, and a cap that refuses out loud.