Icon Picker
Search a grid of line icons, tune size and stroke width live, and copy the selected icon as SVG or JSX.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/icon-picker.json'use client'
import { useState } from 'react'
import './icon-picker.css'
export interface IconPickerProps {
format?: 'svg' | 'jsx'
onPick?: (name: string, svg: string) => void
fill?: 'dark' | 'light'
className?: string
}
const ICONS: [string, string, string][] = [
['home', 'house start', 'M3 11l9-7 9 7M5 10v10h14V10'], ['search', 'find magnifier', 'M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14zM21 21l-5-5'], ['bell', 'notification alert', 'M6 17V11a6 6 0 0 1 12 0v6l2 2H4zM10 21h4'],
['user', 'person profile account', 'M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 21a8 8 0 0 1 16 0'], ['heart', 'like love favourite', 'M12 20s-7-4.5-7-10a4 4 0 0 1 7-2.5A4 4 0 0 1 19 10c0 5.5-7 10-7 10z'], ['star', 'rating favourite', 'M12 3l2.8 5.8 6.2.9-4.5 4.4 1 6.2L12 17.4 6.5 20.3l1-6.2L3 9.7l6.2-.9z'],
['mail', 'email envelope message', 'M3 6h18v12H3zM3 6l9 7 9-7'], ['calendar', 'date schedule', 'M4 6h16v14H4zM4 10h16M8 3v4M16 3v4'], ['clock', 'time', 'M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM12 7v5l3 2'],
['lock', 'secure password', 'M6 11h12v9H6zM8 11V8a4 4 0 0 1 8 0v3'], ['trash', 'delete remove bin', 'M4 7h16M10 11v6M14 11v6M6 7l1 13h10l1-13M9 7V4h6v3'], ['edit', 'pencil write', 'M4 20h4L19 9l-4-4L4 16zM14 6l4 4'],
['plus', 'add new', 'M12 5v14M5 12h14'], ['check', 'done tick ok', 'M5 12l5 5 9-10'], ['x', 'close cancel', 'M6 6l12 12M18 6L6 18'],
['download', 'save arrow', 'M12 4v11M7 10l5 5 5-5M5 20h14'], ['upload', 'arrow send', 'M12 20V9M7 14l5-5 5 5M5 4h14'], ['link', 'url chain', 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1'],
['settings', 'gear cog preferences', 'M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM12 2v3M12 19v3M2 12h3M19 12h3M4.9 4.9L7 7M17 17l2.1 2.1M4.9 19.1L7 17M17 7l2.1-2.1'], ['cart', 'shop basket buy', 'M3 4h2l2.5 11h11L21 7H6.5M9 20h.01M18 20h.01'], ['camera', 'photo picture', 'M4 8h4l2-3h4l2 3h4v11H4zM12 17a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z'],
['map', 'location pin place', 'M12 21s-6-5.5-6-11a6 6 0 0 1 12 0c0 5.5-6 11-6 11zM12 12a2 2 0 1 0 0-4 2 2 0 0 0 0 4z'], ['chat', 'message comment bubble', 'M4 5h16v11H9l-5 4z'], ['sun', 'light day theme', 'M12 16a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM12 2v2M12 20v2M2 12h2M20 12h2M5 5l1.5 1.5M17.5 17.5L19 19M5 19l1.5-1.5M17.5 6.5L19 5'],
]
/** Searchable line-icon picker with live styling. */
export function IconPicker({ format: f0 = 'svg', onPick, fill = 'dark', className }: IconPickerProps) {
const [q, setQ] = useState('')
const [sel, setSel] = useState('heart')
const [size, setSize] = useState(22)
const [stroke, setStroke] = useState(1.75)
const [fmt, setFmt] = useState(f0)
const [copied, setCopied] = useState(false)
const list = ICONS.filter(([n, t]) => `${n} ${t}`.includes(q.toLowerCase().trim()))
const cur = ICONS.find((i) => i[0] === sel)!
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="${stroke}" stroke-linecap="round" stroke-linejoin="round"><path d="${cur[2]}"/></svg>`
const out = fmt === 'svg' ? svg : svg.replace(/stroke-width/g, 'strokeWidth').replace(/stroke-linecap/g, 'strokeLinecap').replace(/stroke-linejoin/g, 'strokeLinejoin').replace(/ xmlns="[^"]+"/, '')
const move = (d: number) => { const i = list.findIndex((x) => x[0] === sel); const n = list[Math.min(list.length - 1, Math.max(0, i + d))]; if (n) { setSel(n[0]); setCopied(false) } }
const Icon = ({ d }: { d: string }) => <svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={stroke} strokeLinecap="round" strokeLinejoin="round" aria-hidden><path d={d} /></svg>
return (
<div className={['sd-icp', className].filter(Boolean).join(' ')} data-fill={fill}>
<label className="sd-icp__in"><input aria-label="Search icons" placeholder="Search icons" value={q} onChange={(e) => setQ(e.target.value)} style={{ fontFamily: 'inherit' }} /><span>{list.length}</span></label>
<ul role="listbox" aria-label="Icons" tabIndex={0} aria-activedescendant={`sd-icp-${sel}`} onKeyDown={(e) => { const m: Record<string, number> = { ArrowRight: 1, ArrowLeft: -1, ArrowDown: 6, ArrowUp: -6 }; if (m[e.key]) { e.preventDefault(); move(m[e.key]) } }}>
{list.length ? list.map(([n, , d]) => <li key={n} id={`sd-icp-${n}`} role="option" aria-selected={sel === n} aria-label={n} onClick={() => { setSel(n); setCopied(false) }}><Icon d={d} /></li>) : <li className="sd-icp__none" role="option" aria-selected={false}>No icons match “{q}”</li>}
</ul>
<label className="sd-icp__rg">Size<input type="range" min={14} max={32} value={size} onChange={(e) => setSize(Number(e.target.value))} /><b>{size}px</b></label>
<label className="sd-icp__rg">Stroke<input type="range" min={1} max={3} step={0.25} value={stroke} onChange={(e) => setStroke(Number(e.target.value))} /><b>{stroke}</b></label>
<div className="sd-icp__seg" role="radiogroup" aria-label="Copy format">{(['svg', 'jsx'] as const).map((f) => <button key={f} type="button" role="radio" aria-checked={fmt === f} onClick={() => { setFmt(f); setCopied(false) }}>{f.toUpperCase()}</button>)}</div>
<div className="sd-icp__sel"><Icon d={cur[2]} /><div><b>{cur[0]}</b><code>{out}</code></div><button type="button" onClick={() => { navigator.clipboard?.writeText(out).catch(() => {}); setCopied(true); onPick?.(cur[0], out) }} aria-live="polite">{copied ? 'Copied' : 'Copy'}</button></div>
</div>
)
}.sd-icp { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-icp[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-icp :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-icp :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-icp .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-icp { width: min(100%, 340px); padding: 14px; grid-template-columns: minmax(0, 1fr); border-radius: 18px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); display: grid; gap: 10px; }
.sd-icp ul { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(6, 1fr); gap: 4px; outline: 0; min-height: 96px; align-content: start; }
.sd-icp li { aspect-ratio: 1; border-radius: 10px; display: grid; place-items: center; cursor: pointer; transition: background-color 120ms; }
.sd-icp li:hover { background: var(--soft); }
.sd-icp li[aria-selected='true'] { box-shadow: inset 0 0 0 1.5px var(--fg); background: var(--soft); }
.sd-icp li svg { transition: width 120ms, height 120ms; }
.sd-icp__sel { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 11px; box-shadow: inset 0 0 0 1px var(--line); }
.sd-icp__sel div { flex: 1; min-width: 0; }
.sd-icp__sel b { display: block; font-size: 13px; }
.sd-icp__sel code { display: block; font: 10.5px ui-monospace, monospace; color: var(--mute); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-icp__sel button { height: 32px; padding: 0 12px; border-radius: 8px; font-size: 12px; font-weight: 600; background: var(--strong) !important; color: var(--on) !important; }
.sd-icp__none { grid-column: 1 / -1; font-size: 12.5px; color: var(--mute); text-align: center; padding: 20px 0; aspect-ratio: auto !important; cursor: default !important; }
.sd-icp__seg { display: flex; padding: 2px; border-radius: 9px; background: var(--soft); } .sd-icp__seg button { flex: 1; height: 30px; border-radius: 7px; font-size: 12px; color: var(--mute) !important; white-space: nowrap; padding: 0 4px; } .sd-icp__seg button[aria-checked='true'] { background: var(--bg) !important; color: var(--fg) !important; box-shadow: inset 0 0 0 1px var(--line); font-weight: 600; }
.sd-icp__rg { display: grid; grid-template-columns: 64px minmax(0, 1fr) 44px; align-items: center; gap: 8px; font-size: 12px; color: var(--mute); } .sd-icp__rg b { color: var(--fg); text-align: right; font-variant-numeric: tabular-nums; } .sd-icp__rg input { width: 100%; margin: 0; accent-color: var(--fg); }
.sd-icp__in { display: flex; align-items: center; gap: 8px; height: 40px; padding: 0 10px; border-radius: 10px; background: var(--soft); } .sd-icp__in:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); } .sd-icp__in input { flex: 1; min-width: 0; border: 0; background: none; color: var(--fg); font: 15px ui-monospace, monospace; outline: 0; } .sd-icp__in span { font-size: 11px; color: var(--mute); }
@media (prefers-reduced-motion: reduce) { .sd-icp *, .sd-icp *::before, .sd-icp *::after { animation: none !important; transition: none !important; } }Overview
Icons are plain SVG paths drawn on a 24-unit grid. Search matches names and tags; arrow keys move through the grid.
Size and stroke sliders restyle every icon at once, and the copy format switches between raw SVG and a JSX snippet.
When to use
- Design tools, CMS icon fields, docs.
When not to use
- Large icon sets: virtualise the grid.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/icon-picker.json$
npx shadcn@latest add https://sundaeui.dev/r/icon-picker.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/icon-picker.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/icon-picker.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy icon-picker.tsx and icon-picker.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { IconPicker } from '@/components/sundae/icon-picker'
<IconPicker />Props
| Prop | Type | Default |
|---|---|---|
onPickCalled when an icon is copied. | (name: string, svg: string) => void | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The grid is a listbox with named options.
- Copy confirmation is announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-icp; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Icon Picker free?
- Yes. Icon Picker is free for personal and commercial projects, with full source on this page.
- How do I install Icon Picker?
- Run npx shadcn@latest add https://sundaeui.dev/r/icon-picker.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Icon Picker support keyboard and screen readers?
- The grid is a listbox with named options. Copy confirmation is announced.
- Does Icon Picker respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Emoji Picker
Components · RefinedKeyboard Shortcuts Sheet
Components · RefinedAPI Key Row
Components · RefinedFAQ Search
Components · RefinedMini Terminal
Components · RefinedWebhook Endpoints
Components · Refined