smithfield-studio / acf-svg-icon-picker
Add ACF field for selecting SVG icons.
Package info
github.com/smithfield-studio/acf-svg-icon-picker
Type:wordpress-plugin
pkg:composer/smithfield-studio/acf-svg-icon-picker
Requires
- php: >=8.2
Requires (Dev)
- carthage-software/mago: ^1.0
- php-stubs/acf-pro-stubs: ^6.0
- phpstan/phpstan: ^2.1
- phpunit/phpunit: ^9.0
- szepeviktor/phpstan-wordpress: ^2.0
- wpackagist-plugin/advanced-custom-fields: ^6.0
- yoast/phpunit-polyfills: ^3.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
ACF SVG Icon Picker Field
Add a field type to ACF for selecting SVG icons from a popup modal. Theme developers provide the icon set; editors pick from it.
Features
- Theme-defined icon sets. Icons live in your theme's
icons/folder by default; configurable via filter. - Multiple icon sets in one picker. Group brand, UI, decorative icons (etc.) into named sections of the popup, or auto-group by subdirectory.
- Per-field group restriction. A given field can opt to show only a subset of the configured groups (
allowed_groups). - Three return formats.
'value'returns the slug (default);'icon'returns the SVG markup;'array'returns a struct with slug, URL, path, title, and group context. - Parent/child theme aware. Child-theme icons override same-slug parent-theme icons.
Requirements
- ACF 5 or 6 (free or Pro).
- PHP 8.2+
Installation
Composer
composer require smithfield-studio/acf-svg-icon-picker
Published on Packagist, so no extra repository declaration is needed. Activate via the plugins admin page.
Manual
Copy the acf-svg-icon-picker folder into wp-content/plugins/ and activate.
Usage
By default, the picker scans the active theme's icons/ folder (parent + child). Drop SVG files in there and they appear in the picker grid.
In your code, read field values via the helper functions — they handle both single-folder and multi-location setups, parent/child theme overrides and groups:
use function SmithfieldStudio\AcfSvgIconPicker\get_svg_icon_uri; use function SmithfieldStudio\AcfSvgIconPicker\get_svg_icon_path; use function SmithfieldStudio\AcfSvgIconPicker\get_svg_icon; use function SmithfieldStudio\AcfSvgIconPicker\get_svg_icon_data; $slug = get_field('my_icon_field'); $icon_url = get_svg_icon_uri($slug); // public URL $icon_path = get_svg_icon_path($slug); // filesystem path $icon_svg = get_svg_icon($slug); // SVG markup as a string $icon_data = get_svg_icon_data($slug); // struct: slug, url, path, title, group_key, group_name
The field has a return_format setting:
'value'(default): returns the slug. Pair with the helpers above.'icon': returns the SVG markup directly soget_field()is enough.'array': returns the same struct asget_svg_icon_data(), ornullwhen the saved icon no longer resolves. Useful when a template wants the URL plus the title or group context in one call without reading the SVG from disk.
Security note:
get_svg_icon()andreturn_format = 'icon'return the SVG file's contents verbatim — no sanitization. The plugin is built on the assumption that icons are committed to your theme/plugin and reviewed by you. Do not pointacf_svg_icon_picker_custom_locationat a directory that accepts user uploads (e.g.wp-content/uploads/) — a malicious editor could land XSS via an SVG<script>element. If you need that workflow, sanitize the svg data.
With ACF Builder / ACF Composer
$fields->addField('my_icon', 'svg_icon_picker', [ 'label' => 'My Icon', 'return_format' => 'value', // 'value' | 'icon' | 'array' ]);
Configuring icon locations
Custom folder in your theme
add_filter('acf_svg_icon_picker_folder', fn() => 'resources/icons/');
Icons outside the theme
add_filter('acf_svg_icon_picker_custom_location', fn() => [ 'path' => WP_CONTENT_DIR . '/icons/', 'url' => content_url() . '/icons/', ]);
Multiple icon sets (grouped picker)
Return a list of locations and the picker groups them under headings — useful for offering distinct icon sets without merging them on disk.
add_filter('acf_svg_icon_picker_custom_location', fn() => [ [ 'name' => 'Brand', // group heading 'key' => 'brand', // optional, derived from name when omitted 'path' => WP_CONTENT_DIR . '/icons/brand/', 'url' => content_url() . '/icons/brand/', ], [ 'name' => 'UI', 'path' => WP_CONTENT_DIR . '/icons/ui/', 'url' => content_url() . '/icons/ui/', ], ]);
In grouped mode, saved values are stored as groupkey.slug (e.g. brand.discord) so the same slug can live in multiple groups without colliding. Resolution is strict — a saved value whose group prefix no longer matches any configured group renders a missing-asset state in the editor rather than silently substituting an icon from another group. Empty locations (no SVGs found) are silently skipped. Group keys that slugify the same way auto-disambiguate with -2, -3, … suffixes.
Auto-grouping by subdirectory
For projects that already organise icons into subfolders, set group_by_subdir on a single location and each top-level subfolder becomes its own group:
// resources/icons/ // ├── brand/ → "Brand" group // ├── ui/ → "UI" group // └── decorative/ → "Decorative" group add_filter('acf_svg_icon_picker_custom_location', fn() => [ 'path' => get_stylesheet_directory() . '/resources/icons/', 'url' => get_stylesheet_directory_uri() . '/resources/icons/', 'group_by_subdir' => true, ]);
The subfolder name (Title-Cased) becomes the group heading; the slugified name becomes the group key. Folder names with spaces or capitals (Brand Icons/) are matched via sanitize_title(), so brand-icons.foo resolves the literal folder.
Restricting which groups appear per field
When the custom-location filter declares groups, each field can opt to show only a subset via the allowed_groups setting (a list of group keys). Empty / unset = show all.
$fields->addField('industry_icon', 'svg_icon_picker', [ 'label' => 'Industry icon', 'allowed_groups' => ['nucleo', 'ui'], // hide 'brand', 'decorative', etc. ]);
In the field-editor UI this appears as a checkbox group listing every available group; the setting is hidden when no groups are configured.
A saved value from a group outside the list shows as missing in the editor, and get_field() returns a missing icon for it ('', or null for the 'array' return format). An allowlist whose keys match no configured group is ignored.
How values are stored
The saved value is always a string. Its shape depends on which mode the picker is in at save time:
| Picker mode | Stored value | Example |
|---|---|---|
Default theme icons/ folder, or single {path, url} filter |
Bare slug | arrow-down |
Multiple locations, or group_by_subdir => true |
Composite groupkey.slug |
brand.discord |
Resolution rules:
- The helper functions (
get_svg_icon_uri(),get_svg_icon_path(),get_svg_icon()) accept both forms and resolve correctly. - Only those two shapes resolve: a slug is
a-z,0-9,_and-, and a group key the same plus%. The helpers return''for anything else (a path separator,.., uppercase), and the field saves such a value as''. The legacyarrow downform is saved as its slug, and formats as its slug until the post is re-saved. - Composite values are strict: if the
groupkeyprefix no longer matches any configured group, the field renders the missing-asset state in the editor instead of substituting a same-slug icon from another group. - Bare values are first-match-wins across all configured locations (legacy back-compat for values saved before grouping was introduced).
- In grouped mode, saving a previously-bare value through the picker may auto-canonicalise to the composite form when exactly one configured group claims the slug — see
update_value().
If you read field values in custom code, prefer the helper functions; they accept either form.
WPGraphQL
If WPGraphQL and wp-graphql-acf are active, the field is automatically registered as an SvgIcon GraphQL object type:
{
page(id: "...") {
myIcon {
slug # e.g. "brand.discord"
url # public URL of the resolved SVG
svg # inline SVG markup
}
}
}
slug is the bare slug in flat mode and groupkey.slug in grouped mode; url and svg are resolved using the same helpers as PHP-side code, so all three filter shapes (single, list, group_by_subdir) are honoured.
The field's return_format doesn't change the GraphQL output, including inside groups, repeaters, flexible content, clone fields and ACF blocks. An empty field, or a value outside the field's allowed_groups, resolves to null.
Filters
| Filter | Signature | Default | Since |
|---|---|---|---|
acf_svg_icon_picker_folder |
(string $folder): string |
'icons/' |
4.0.0 |
acf_svg_icon_picker_custom_location |
(false|array): false|array. Return false (or null, '', []) to fall through to theme dirs, an array {path, url, name?, key?, group_by_subdir?} for a single location, or a list of such arrays for grouped mode |
false |
4.0.0 (single); 5.0.0 (list / group_by_subdir) |
// Change the theme-relative folder. add_filter('acf_svg_icon_picker_folder', fn() => 'resources/icons/'); // Single custom location outside the theme. add_filter('acf_svg_icon_picker_custom_location', fn() => [ 'path' => WP_CONTENT_DIR . '/icons/', 'url' => content_url() . '/icons/', ]);
See Configuring icon locations above for grouped + subdir-mode shapes.
Upgrading
Coming from v4 (or earlier)? See UPGRADING.md.
Development
Tests
PHPUnit-based, running against a real WordPress test environment. One-time setup of the WP test suite:
bash bin/install-wp-tests.sh <db-name> <db-user> <db-pass> [db-host] [wp-version] # example bash bin/install-wp-tests.sh wordpress_test root '' localhost latest
Then:
composer test # PHPUnit composer phpstan # static analysis (level 10) composer format # mago — format PHP composer format:check # mago — format check (used by CI)
JS / CSS code quality
JS and CSS are formatted with oxfmt and JS is linted with oxlint (both Rust-based, very fast). Install once:
npm install
Then:
npm run format # format JS, CSS, JSON npm run format:check # check formatting (used by CI) npm run lint # oxlint on JS npm run lint:fix # auto-fix lint issues npm run check # format:check + lint, run together
CI runs the full quality suite (composer phpstan, composer format:check, npm run format -- --check, npm run lint) on every push and PR via .github/workflows/code-quality.yml. The PHPUnit suite runs separately via .github/workflows/php-unit-tests.yml.
Changelog
See CHANGELOG.md for the full version history, or the GitHub releases page for tagged downloads.