Skip to content

Instantly share code, notes, and snippets.

@PhantomKnight287
Created March 12, 2026 12:32
Show Gist options
  • Select an option

  • Save PhantomKnight287/98df5dfaef4ae65df1c6c22091ef72b2 to your computer and use it in GitHub Desktop.

Select an option

Save PhantomKnight287/98df5dfaef4ae65df1c6c22091ef72b2 to your computer and use it in GitHub Desktop.
import { Icon } from "@iconify/react"
import { useVirtualizer } from "@tanstack/react-virtual"
import { useState, useEffect, useRef, useCallback,} from "react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover"
interface IconifySearchResponse {
icons: string[]
total: number
limit: number
start: number
collections: Record<
string,
{
name: string
total: number
author: { name: string; url: string }
license: { title: string; spdx: string; url: string }
}
>
}
interface IconPickerProps {
/** Controlled value – an Iconify icon name like `"mdi:home"` */
value?: string
/** Initial value for uncontrolled usage */
defaultValue?: string
/** Called when the user picks an icon */
onChange?: (value: string) => void
/** Placeholder text shown when no icon is selected */
placeholder?: string
/** Additional class names for the trigger button */
className?: string
/** Whether the picker is disabled */
disabled?: boolean
}
const COLUMNS = 8
const ICON_CELL_SIZE = 40 // px – height of each row / cell
const API_BASE = "https://api.iconify.design"
const SEARCH_LIMIT = 999
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(id)
}, [value, delay])
return debounced
}
function IconPicker({
value: controlledValue,
defaultValue,
onChange,
placeholder = "Pick an icon…",
className,
disabled,
}: IconPickerProps) {
const isControlled = controlledValue !== undefined
const [internalValue, setInternalValue] = useState(
defaultValue ?? "",
)
const selectedIcon = isControlled ? controlledValue : internalValue
const handleSelect = useCallback(
(icon: string) => {
if (!isControlled) setInternalValue(icon)
onChange?.(icon)
setOpen(false)
},
[isControlled, onChange],
)
const [open, setOpen] = useState(false)
const [query, setQuery] = useState("")
const debouncedQuery = useDebounce(query, 300)
const [icons, setIcons] = useState<string[]>([])
const [loading, setLoading] = useState(false)
const [total, setTotal] = useState(0)
useEffect(() => {
if (!open) return
const trimmed = debouncedQuery.trim()
if (!trimmed) {
setIcons([])
setTotal(0)
return
}
let cancelled = false
setLoading(true)
const url = `${API_BASE}/search?query=${encodeURIComponent(trimmed)}&limit=${SEARCH_LIMIT}`
fetch(url)
.then((res) => res.json() as Promise<IconifySearchResponse>)
.then((data) => {
if (cancelled) return
setIcons(data.icons)
setTotal(data.total)
})
.catch(() => {
if (cancelled) return
setIcons([])
setTotal(0)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => {
cancelled = true
}
}, [debouncedQuery, open])
useEffect(() => {
if (open) {
setQuery("")
setIcons([])
setTotal(0)
}
}, [open])
const parentRef = useRef<HTMLDivElement>(null)
const rowCount = Math.ceil(icons.length / COLUMNS)
const virtualizer = useVirtualizer({
count: rowCount,
getScrollElement: () => parentRef.current,
estimateSize: () => ICON_CELL_SIZE,
overscan: 5,
})
const inputRef = useRef<HTMLInputElement>(null)
useEffect(() => {
if (open) {
// small delay to wait for popover animation
const id = setTimeout(() => inputRef.current?.focus(), 50)
return () => clearTimeout(id)
}
}, [open])
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="default"
disabled={disabled}
className={cn(
"w-full justify-start gap-2 font-normal",
!selectedIcon && "text-muted-foreground",
className,
)}
>
{selectedIcon ? (
<>
<Icon icon={selectedIcon} className="size-4 shrink-0" />
<span className="truncate">{selectedIcon}</span>
</>
) : (
<span>{placeholder}</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[380px] gap-2 p-3"
align="start"
onOpenAutoFocus={(e) => e.preventDefault()}
>
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search icons in english..."
className="h-8 text-sm"
/>
<div className="flex items-center justify-between px-1 text-xs text-muted-foreground">
{loading ? (
<span>Searching…</span>
) : ( <span>Type to search icons</span>)}
</div>
<div
ref={parentRef}
className="h-[280px] overflow-auto rounded-lg border"
>
{icons.length > 0 ? (
<div
className="relative w-full"
style={{ height: virtualizer.getTotalSize() }}
>
{virtualizer.getVirtualItems().map((virtualRow) => {
const startIdx = virtualRow.index * COLUMNS
const rowIcons = icons.slice(startIdx, startIdx + COLUMNS)
return (
<div
key={virtualRow.index}
className="absolute left-0 top-0 flex w-full"
style={{
height: virtualRow.size,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{rowIcons.map((icon) => (
<button
key={icon}
type="button"
title={icon}
onClick={() => handleSelect(icon)}
className={cn(
"flex items-center justify-center rounded-md transition-colors hover:bg-accent",
"size-[40px] shrink-0 cursor-pointer",
selectedIcon === icon &&
"bg-primary/10 text-primary ring-1 ring-primary/30",
)}
>
<Icon icon={icon} className="size-5" />
</button>
))}
</div>
)
})}
</div>
) : !loading && query.trim() ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No icons found
</div>
) : !loading ? (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
Start typing to search
</div>
) : null}
</div>
</PopoverContent>
</Popover>
)
}
export { IconPicker, type IconPickerProps }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment