Skip to content

Instantly share code, notes, and snippets.

@notflip
Last active June 2, 2026 19:09
Show Gist options
  • Select an option

  • Save notflip/5f3e1c078b329f74c956dc7f863e4793 to your computer and use it in GitHub Desktop.

Select an option

Save notflip/5f3e1c078b329f74c956dc7f863e4793 to your computer and use it in GitHub Desktop.
import type { TextField } from "payload";
type IconFieldOptions = {
name?: string;
label?: string;
required?: boolean;
};
export function iconField({
name = "icon",
label = "Icoon",
required = false,
}: IconFieldOptions = {}): TextField {
return {
type: "text",
name,
label,
required,
admin: {
components: {
Field: {
path: "@kit/fields/icon/IconPickerComponent#IconPickerComponent",
},
},
},
};
}
import { iconNames } from "lucide-react/dynamic";
// Authoritative kebab-case list from lucide-react itself — no filtering needed.
// Names are in the format expected by DynamicIcon: "star", "arrow-right", etc.
export const ICON_NAMES: string[] = [...iconNames].sort();
"use client";
import { FieldLabel, useField } from "@payloadcms/ui";
import { useVirtualizer } from "@tanstack/react-virtual";
import * as LucideIcons from "lucide-react";
import type { LucideIcon } from "lucide-react";
import type { TextFieldClientProps } from "payload";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { ICON_NAMES } from "./icon-list";
const ITEM_HEIGHT = 36;
// Icons are stored as kebab-case ("arrow-right"); LucideIcons keys are PascalCase ("ArrowRight")
function toPascalCase(kebab: string): string {
return kebab.split("-").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join("");
}
function getIcon(kebabName: string): LucideIcon | undefined {
return (LucideIcons as unknown as Record<string, LucideIcon>)[toPascalCase(kebabName)];
}
export const IconPickerComponent: React.FC<TextFieldClientProps> = ({ field, path }) => {
const { value, setValue } = useField<string>({ path });
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const wrapperRef = useRef<HTMLDivElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const searchRef = useRef<HTMLInputElement>(null);
const filteredIcons = useMemo(() => {
const q = search.trim().toLowerCase();
return q ? ICON_NAMES.filter((n) => n.toLowerCase().includes(q)) : ICON_NAMES;
}, [search]);
const virtualizer = useVirtualizer({
count: filteredIcons.length,
getScrollElement: () => listRef.current,
estimateSize: () => ITEM_HEIGHT,
overscan: 5,
});
useEffect(() => {
if (!open) {
setSearch("");
return;
}
requestAnimationFrame(() => searchRef.current?.focus());
const onDown = (e: MouseEvent) => {
if (!wrapperRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, [open]);
const SelectedIcon = value ? getIcon(value) : undefined;
return (
<div className="field-type" ref={wrapperRef} style={{ position: "relative" }}>
<FieldLabel label={field.label} />
<button
type="button"
onClick={() => setOpen((v) => !v)}
style={{
display: "flex",
alignItems: "center",
gap: 8,
width: "100%",
padding: "8px 10px",
border: "1px solid var(--theme-elevation-150)",
borderRadius: 6,
background: "var(--theme-input-bg, var(--theme-bg))",
color: "var(--theme-text)",
fontSize: 14,
cursor: "pointer",
textAlign: "left",
}}
>
{SelectedIcon ? (
<>
<SelectedIcon width={16} height={16} strokeWidth={1.75} />
<span style={{ flex: 1 }}>{value}</span>
<span
role="button"
aria-label="Verwijder icoon"
onClick={(e) => {
e.stopPropagation();
setValue("");
}}
style={{ opacity: 0.45, fontSize: 11, lineHeight: 1, padding: "2px 4px" }}
>
</span>
</>
) : (
<span style={{ opacity: 0.45 }}>Kies een icoon…</span>
)}
</button>
{open && (
<div
style={{
position: "absolute",
top: "calc(100% + 4px)",
left: 0,
right: 0,
zIndex: 50,
background: "var(--theme-bg)",
border: "1px solid var(--theme-elevation-150)",
borderRadius: 6,
boxShadow: "0 6px 20px rgba(0,0,0,0.12)",
}}
>
<div style={{ padding: "8px 8px 4px" }}>
<input
ref={searchRef}
type="search"
placeholder="Zoek icoon…"
value={search}
onChange={(e) => setSearch(e.target.value)}
style={{
width: "100%",
padding: "6px 10px",
border: "1px solid var(--theme-elevation-150)",
borderRadius: 4,
background: "var(--theme-elevation-50, rgba(0,0,0,0.03))",
color: "var(--theme-text)",
fontSize: 13,
boxSizing: "border-box",
}}
/>
</div>
<div style={{ padding: "2px 10px 4px", fontSize: 11, opacity: 0.45 }}>
{filteredIcons.length} iconen
</div>
<div ref={listRef} style={{ height: 280, overflowY: "auto" }}>
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
{virtualizer.getVirtualItems().map((row) => {
const name = filteredIcons[row.index];
const Icon = getIcon(name);
const isSelected = value === name;
return (
<div
key={row.key}
data-index={row.index}
ref={virtualizer.measureElement}
onClick={() => {
setValue(name);
setOpen(false);
}}
style={{
position: "absolute",
top: row.start,
left: 0,
right: 0,
height: ITEM_HEIGHT,
display: "flex",
alignItems: "center",
gap: 8,
padding: "0 10px",
cursor: "pointer",
fontSize: 13,
background: isSelected ? "var(--theme-elevation-100)" : "transparent",
color: isSelected ? "var(--theme-text)" : "var(--theme-elevation-800)",
}}
>
{Icon && <Icon width={15} height={15} strokeWidth={1.5} />}
<span>{name}</span>
{isSelected && (
<span style={{ marginLeft: "auto", fontSize: 11, opacity: 0.6 }}>✓</span>
)}
</div>
);
})}
</div>
</div>
</div>
)}
</div>
);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment