Back

Icon Picker

A React component for icon picker.

Component

Note that, this component requires some dependencies like react-icons, Shadcn UI and lodash.

Create a file called IconItem.tsx with this snippet.

import React from 'react';
import { iconMap } from '@/lib/icons';
import { clsx } from 'clsx';

function formatIconName(iconName: string): string {
  return iconName
    .replace(/^Fa/, '')
    .replace(/([a-z])([A-Z])/g, '$1 $2');
}

const IconItem = React.memo(function IconItem({
  iconName,
  isActive,
  onClick,
  showTitle = false,
}: {
  iconName: string;
  isActive: boolean;
  onClick: () => void;
  showTitle?: boolean;
}) {
  const IconComponent = iconMap[iconName as keyof typeof iconMap];
  if (!IconComponent) return null;

  return (
    <button
      type="button"
      title={formatIconName(iconName)}
      className={clsx(
        "group flex flex-col items-center justify-center cursor-pointer transition duration-200 ease-in-out",
        showTitle ? "rounded-lg p-3" : "rounded-full h-14 w-14",
        isActive
          ? "bg-zinc-700/80 text-white shadow-md"
          : "text-zinc-400 hover:bg-zinc-800/70 hover:text-white"
      )}
      onClick={(e) => {
        e.preventDefault();
        onClick();
      }}
      aria-label={`Select ${iconName}`}
    >
      <div className="flex flex-col items-center space-y-2">
        <div className="text-2xl">
          <IconComponent />
        </div>
        {showTitle && <span
          className={clsx(
            "text-xs font-medium transition-colors truncate w-[60px]",
            isActive ? "text-white" : "text-zinc-400 group-hover:text-zinc-200"
          )}
        >
          {formatIconName(iconName)}
        </span>}
      </div>
    </button>
  );
});

Create a another file called IconPicker.tsx with this snippet and import it in your component.

import { debounce } from 'lodash';
import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react';
import { Popover, PopoverContent, PopoverTrigger } from '@components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@components/ui/tooltip';
import IconItem from '@components/IconItem';
import Icon from '@components/Icon';
import { allIcons, iconMap } from '@/lib/icons';
import { clsx } from 'clsx';

const IconPicker = ({ initiallyLoadedIcons = 20, defaultIcon = 'FaQuestion' }: { initiallyLoadedIcons?: number, defaultIcon?: string }) => {
  const [activeIcon, setActiveIcon] = useState(defaultIcon);
  const [popoverOpen, setPopoverOpen] = useState(false);
  const [searchQuery, setSearchQuery] = useState('');
  const [loadedIcons, setLoadedIcons] = useState<number>(initiallyLoadedIcons);
  const [isLoading, setIsLoading] = useState(false);
  const listRef = useRef<HTMLDivElement>(null);

  // Debounced search handler
  const debouncedSetQuery = useMemo(
    () =>
      debounce((value: string) => {
        setSearchQuery(value);
        setLoadedIcons(initiallyLoadedIcons); // Reset icons when searching
      }, 250),
    [initiallyLoadedIcons]
  );

  const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    debouncedSetQuery(e.target.value);
  }, [debouncedSetQuery]);

  const filteredIcons = useMemo(() => {
    return searchQuery
      ? allIcons.filter((icon) =>
        icon.toLowerCase().includes(searchQuery.toLowerCase())
      )
      : allIcons;
  }, [searchQuery]);

  const visibleIcons = useMemo(
    () => filteredIcons.slice(0, loadedIcons),
    [filteredIcons, loadedIcons]
  );

  const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
    if (isLoading) return;

    const target = e.target as HTMLDivElement;
    const nearBottom =
      target.scrollTop + target.clientHeight >= target.scrollHeight - 50;

    if (nearBottom && loadedIcons < filteredIcons.length) {
      setIsLoading(true);
      setTimeout(() => {
        setLoadedIcons((prev) => Math.min(prev + 15, filteredIcons.length));
        setIsLoading(false);
      }, 100);
    }
  };

  // Cleanup debounce on unmount
  useEffect(() => {
    return () => debouncedSetQuery.cancel();
  }, [debouncedSetQuery]);

  return (
    <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
      <div className="flex items-center justify-center h-96">
        <Tooltip delayDuration={600}>
          <TooltipTrigger asChild>
            <PopoverTrigger
              type="button"
              className="text-2xl transition-colors duration-200 hover:text-white/90 hover:bg-zinc-700/60 p-3 rounded-full"
            >
              {activeIcon ? <Icon icon={activeIcon} /> : <Icon icon='RxValueNone' />}
            </PopoverTrigger>
          </TooltipTrigger>
          <TooltipContent>Change icon</TooltipContent>
        </Tooltip>
      </div>
      <PopoverContent className="!p-0 !border-none !w-auto !rounded-xl">
        <div className="icon-picker w-full max-w-lg p-4 bg-zinc-900 text-white border border-zinc-700 rounded-lg shadow-lg">
          <input
            type="text"
            placeholder="Search icons..."
            autoComplete="off"
            onChange={handleSearch}
            className="w-full py-2 px-4 mb-4 border border-zinc-600 bg-zinc-800 text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
          <div
            ref={listRef}
            className="icon-list no-scrollbar grid grid-cols-4 gap-2 overflow-y-auto overflow-x-hidden max-h-72 scrollbar-thin scrollbar-thumb-zinc-600 scrollbar-track-zinc-800"
            onScroll={handleScroll}
          >
            {visibleIcons.map((iconName) => (
              <IconItem
                key={iconName}
                iconName={iconName}
                isActive={iconName === activeIcon}
                showTitle
                onClick={() => {
                  setActiveIcon(iconName)
                  setPopoverOpen(false)
                }}
              />
            ))}

            {loadedIcons < filteredIcons.length && (
              <div className="col-span-4 flex items-center justify-center py-4">
                {isLoading ? (
                  <div className="text-xs text-zinc-500 animate-pulse">
                    Loading...
                  </div>
                ) : (
                  <div className="h-4" />
                )}
              </div>
            )}
          </div>
        </div>
      </PopoverContent>
    </Popover>
  );
};

export default IconPicker;

Important, I know this is a vague solution but copy iconMap data by .


Icon Component

And finally, create Icon.tsx.

import { iconMap } from "@/lib/icons";
import { RxValueNone } from "react-icons/rx";

function Icon({ icon }: { icon: string }) {
  const checkIconAvailability = () => {
    const Icon = iconMap[icon as keyof typeof iconMap];
    if (!Icon) {
      return <RxValueNone />; // Fallback
    }
    return <Icon />;
  };
  return (
    <>
      {checkIconAvailability()}
    </>
  )
}

export default Icon;

Bonus

Paste this code into your main css file to hide scrollbar.

@layer utilities {
  /* For Chrome, Safari and Opera */
  .no-scrollbar::-webkit-scrollbar {
    display: none;
  }
  /* For IE, Edge and Firefox */
  .no-scrollbar {
    -ms-overflow-style: none; /* IE and Edge */
    scrollbar-width: none; /* Firefox */
  }
}

Props for IconPicker

These are the props for the IconPicker component. Customize them according to your needs.

PropTypeDefaultDescription
initiallyLoadedIconsnumber20Number of icons loaded initially
defaultIconstringFaQuestionDefault icon to be selected

Props for the IconItem component.

PropTypeDefaultDescription
iconNamestring-Name of the icon
isActiveboolean-Whether the icon is active or not
onClick() => void-Function to be called when the icon is clicked
showTitlebooleanfalseWhether to show the title or not

Made by Dhvanit. Visit my portfolio.