Inputs

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronLeft, ChevronRight, ChevronsUpDown, Search, Shapes, X } from "lucide-react"
import { Popover as PopoverPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"

/* ------------------------------------------------------------------ *
 * Data model
 * ------------------------------------------------------------------ */

/** Anything that draws itself at a class-driven size. Lucide exports fit as-is. */
export type IconPickerIconComponent = React.ComponentType<{ className?: string }>

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/icon-picker.json

Prompt

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 exactly columns × 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.

On This Page