Picker Fields

The Picker Fields app brings 6 new field types to Agility: icon pickers for four icon libraries, plus a hex color picker and a named color picker.

The Picker Fields app

What is the Picker Fields app?

Picker Fields adds six field types to your Content Models and Page Modules:

  • Icon — Lucide
  • Icon — Heroicons
  • Icon — Font Awesome
  • Icon — Simple Icons (brands)
  • Color — Hex
  • Color — Named

Icons and colors usually end up in a plain text box, with the editor expected to remember that the value is arrow-right and not arrow_right or ArrowRight. These fields replace that box with a picker: editors browse and search the library visually, and the field writes the exact value your front end expects.

There is nothing to configure. The app has no settings and no API keys — install it and the six fields are available.

Installing the Picker Fields App

In order to use an App in Agility, you need to install it in your instance. You can do this from the Marketplace under Settings > Apps. You need to be an administrator on the instance to install an app.

  1. Navigate to Settings > Apps
  2. Click Install
  3. Type Picker Fields into the search box, then select the Picker Fields app
  4. Review the app details, then click Continue
  5. Click Finish

There is no configuration step, because the app has no settings. The six field types are available immediately for use in your Content Models and Page Modules.

Using the Picker Fields App

After installing the Picker Fields App, you can create Content Models or Page Modules that use any of the six field types it provides.

The icon field on a content model, with a value set and with none set

Each icon field opens a browser showing every icon in its library, with search and — for libraries that have styles — a filter for the style. Font Awesome carries over 2,800 icons and Simple Icons over 3,400, so search is the fastest way in. The selected icon is shown in the field, and can be cleared at any time.

The icon browser, with search and a style filter

Color — Hex offers a saturation square, a hue slider, and a text box for typing or pasting a value. In Chromium browsers it also offers an eyedropper, for picking a color from anywhere on screen.

The hex color field with its picker open

Color — Named lists the 148 CSS named colors, ordered by appearance rather than alphabetically, so the blues sit together and a color can be found by eye.

The named color field

Outputting Content from Picker Fields on your Website

Every Picker Field stores a plain text string, never JSON. Your front end can use the value directly, and a field can be switched to or from an ordinary text field without migrating any content.

FieldExample stored value
Icon — Lucidearrow-right
Icon — Heroiconsoutline/academic-cap
Icon — Font Awesomesolid/star, brands/github
Icon — Simple Icons (brands)github
Color — Hex#0F62FE
Color — Namedrebeccapurple

Rendering Lucide icons in Next.js

An editor can pick any of Lucide's ~2,100 icons, so your site cannot keep a hand-written map of imported components — it has to resolve a name it has never seen before.

The obvious approach, importing the whole icon set so a name can be looked up, ships every icon to the browser. Use Lucide's own dynamicIconImports map instead: each entry is a static import the bundler can see, so you pay for one small server-side chunk per icon actually used, and nothing in the browser bundle.

// lib/icons/resolve.ts
import "server-only"
import dynamicIconImports from "lucide-react/dynamicIconImports"

export type IconNode = [tag: string, attrs: Record<string, string | number>][]
export interface ResolvedIcon {
  node: IconNode
  size: number
}

const cache = new Map<string, ResolvedIcon | null>()
type IconImports = Record<string, undefined | (() => Promise<unknown>)>

/** Turn a Lucide name stored by the CMS into the data needed to draw it. */
export const resolveIcon = async (name?: string | null): Promise<ResolvedIcon | null> => {
  const id = name?.trim().toLowerCase()
  if (!id) return null

  const cached = cache.get(id)
  if (cached !== undefined) return cached

  const load = (dynamicIconImports as unknown as IconImports)[id]
  if (!load) {
    cache.set(id, null)
    return null
  }

  try {
    const mod = (await load()) as {
      __iconData?: { node: IconNode; size?: number }
      __iconNode?: IconNode
    }

    // lucide-react 1.46 renamed __iconNode to __iconData. Read both: the older
    // shape fails silently, which is a miserable thing to debug after a bump.
    const icon = mod.__iconData?.node
      ? { node: mod.__iconData.node, size: mod.__iconData.size ?? 24 }
      : mod.__iconNode
        ? { node: mod.__iconNode, size: 24 }
        : null

    cache.set(id, icon)
    return icon
  } catch {
    cache.set(id, null)
    return null
  }
}

Returning null for an unknown name rather than throwing matters: a stale value left in the CMS should cost you one missing icon, not the whole page.

Then render the resolved data. Keeping this component free of any Lucide import means a client component can use it without pulling the icon set into the browser bundle:

// lib/icons/glyph.tsx
import { createElement } from "react"
import type { ResolvedIcon } from "./resolve"

export const Glyph = ({ icon, className }: { icon?: ResolvedIcon | null; className?: string }) => {
  if (!icon?.node.length) return null

  return (
    <svg
      xmlns="http://www.w3.org/2000/svg"
      width={icon.size}
      height={icon.size}
      viewBox={`0 0 ${icon.size} ${icon.size}`}
      fill="none"
      stroke="currentColor"
      strokeWidth={2}
      strokeLinecap="round"
      strokeLinejoin="round"
      className={className}
      aria-hidden="true"
    >
      {icon.node.map(([tag, attrs], index) => {
        // Lucide ships its own React key inside the attrs; pull it out so it is
        // passed as a key rather than spread onto the DOM element.
        const { key, ...rest } = attrs
        return createElement(tag, { key: key ?? index, ...rest })
      })}
    </svg>
  )
}

Use the two together from a server component:

import { resolveIcon } from "@/lib/icons/resolve"
import { Glyph } from "@/lib/icons/glyph"

export default async function FeatureCard({ fields }: { fields: { icon: string; title: string } }) {
  const icon = await resolveIcon(fields.icon)

  return (
    <div className="flex items-center gap-3">
      <Glyph icon={icon} className="size-6" />
      <h3>{fields.title}</h3>
    </div>
  )
}

Because the SVG uses stroke="currentColor", the icon takes its color from CSS. Pair an icon field with a Color — Hex field and your editors can set both.

Heroicons and Font Awesome

These two store the style and the name separated by a slash, because the same name exists in more than one style. Split the value on the slash and use the style to pick the right import path or CSS class:

const [style, name] = value.split("/")   // "solid/star"

// Font Awesome, via its CSS classes
const className = `fa-${style} fa-${name}`

Colors

Both color fields store a value that CSS accepts as-is, so it can go straight into a style attribute or a CSS custom property:

<div style={{ "--accent": fields.accentColor } as React.CSSProperties}>
  <button className="bg-[var(--accent)]">Get started</button>
</div>

Transparency

Color — Hex always stores the 6-digit #RRGGBB form. It never produces the 8-digit #RRGGBBAA form, because 8-digit hex is not safe everywhere a value might land — older Safari, email clients, and anything that parses the value itself rather than handing it to a browser.

Icon licensing

Icons are redistributed from their upstream packages, under their own licences: Lucide (ISC), Heroicons (MIT), Font Awesome Free (CC BY 4.0) and Simple Icons (CC0 1.0).

Simple Icons are brand logos. Using one is not a licence to use the brand it represents — check the brand's own guidelines before publishing.