Skip to content

IconGrid

MD · 1950B gzip budget

Selectable icon grid with search and category filtering.

Category
Media & layout
Budget
md, several states or a live subscription
External runtime
React / React DOM only
axe fixtureSSR rendersource scanbundle budget

Preview

Live fixture
Preview locale

Try the component here. Locale-aware formatting and direction follow the selected language; example content and labels are not automatically translated. Overlay demos use interactive launchers.

Install

npx shadcn@latest add https://gear5-ui.vercel.app/r/icon-grid.json

Copies the source into your project.

New here? Set up Tailwind and import aliases first →

Source & props

Edit on GitHub ↗

Prop types and inline documentation are included below. This is repository source; the installer rewrites shared imports for your project.

"use client";

import React, { useCallback, useMemo, useRef, useState } from "react";

export interface IconGridProps {
  icons: { name: string; path: string }[];
  onSelect?: (name: string) => void;
  label?: string;
}

export function IconGrid({
  icons,
  onSelect,
  label = "Icon picker",
}: IconGridProps) {
  const [search, setSearch] = useState("");
  const [activeIndex, setActiveIndex] = useState(0);
  const gridRef = useRef<HTMLDivElement>(null);

  const filtered = useMemo(() => {
    if (!search) return icons;
    const q = search.toLowerCase();
    return icons.filter((icon) => icon.name.toLowerCase().includes(q));
  }, [icons, search]);

  const handleKeyDown = useCallback(
    (e: React.KeyboardEvent) => {
      const cols = gridRef.current
        ? Math.floor(
            gridRef.current.getBoundingClientRect().width / 48
          )
        : 4;

      if (e.key === "ArrowRight") {
        e.preventDefault();
        setActiveIndex((i) => Math.min(i + 1, filtered.length - 1));
      } else if (e.key === "ArrowLeft") {
        e.preventDefault();
        setActiveIndex((i) => Math.max(i - 1, 0));
      } else if (e.key === "ArrowDown") {
        e.preventDefault();
        setActiveIndex((i) => Math.min(i + cols, filtered.length - 1));
      } else if (e.key === "ArrowUp") {
        e.preventDefault();
        setActiveIndex((i) => Math.max(i - cols, 0));
      } else if (e.key === "Enter" || e.key === " ") {
        e.preventDefault();
        if (filtered[activeIndex]) {
          onSelect?.(filtered[activeIndex].name);
        }
      }
    },
    [filtered, activeIndex, onSelect]
  );

  return (
    <div
      role="dialog"
      aria-label={label}
      style={{
        display: "flex",
        flexDirection: "column",
        gap: "0.5rem",
        padding: "0.75rem",
        borderRadius: "0.75rem",
        border: "1px solid #e0e0e0",
        backgroundColor: "#fff",
        fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
      }}
    >
      <label style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
        <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="#999" strokeWidth="1.5">
          <circle cx="7" cy="7" r="5" />
          <path d="M11 11l3 3" strokeLinecap="round" />
        </svg>
        <input
          type="search"
          value={search}
          onChange={(e) => {
            setSearch(e.target.value);
            setActiveIndex(0);
          }}
          placeholder="Search icons…"
          aria-label="Search icons"
          style={{
            flex: 1,
            border: "none",
            outline: "none",
            fontSize: "0.875rem",
            padding: "0.375rem 0",
            backgroundColor: "transparent",
          }}
        />
      </label>
      <div
        ref={gridRef}
        role="grid"
        aria-label="Icons"
        tabIndex={0}
        onKeyDown={handleKeyDown}
        style={{
          display: "grid",
          gridTemplateColumns: "repeat(auto-fill, minmax(40px, 1fr))",
          gap: "0.25rem",
          maxHeight: 240,
          overflowY: "auto",
        }}
      >
        {filtered.map((icon, i) => (
          <div key={icon.name} role="row">
            <button
              role="gridcell"
              aria-label={icon.name}
              aria-selected={i === activeIndex}
              onClick={() => onSelect?.(icon.name)}
              style={{
                width: 40,
                height: 40,
                display: "flex",
                alignItems: "center",
                justifyContent: "center",
                border: i === activeIndex ? "2px solid #1a73e8" : "1px solid transparent",
                borderRadius: "0.375rem",
                backgroundColor: i === activeIndex ? "#e8f0fe" : "transparent",
                cursor: "pointer",
                background: "none",
                padding: 0,
              }}
            >
              <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#333" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
                <path d={icon.path} />
              </svg>
            </button>
          </div>
        ))}
        {filtered.length === 0 && (
          <p style={{ gridColumn: "1 / -1", textAlign: "center", color: "#999", fontSize: "0.875rem", padding: "1rem" }}>
            No icons found
          </p>
        )}
      </div>
    </div>
  );
}

What CI checks

Quality contract
  • Bundled, minified and gzipped against its tier budget
  • Audited by axe in the state previewed above
  • Rendered through react-dom/server with no browser globals
  • Scanned for network calls, dangerous sinks, and unguarded animation