ACF Button Link Component — Reusable PHP Renderer
A reusable wrapper for ACF Link fields — null-safe rendering, variant system for primary/secondary/ghost/outline styles, optional inline SVG icons positioned before or after the label, and full attribute escape. One function call per button.
This ACF button link component PHP snippet renders ACF Link fields as reusable buttons with variants, optional icons, safe attributes, and escaped output.
Use this when your theme has buttons powered by ACF Link fields — CTAs, navigation links, hero buttons — and you want consistent rendering with variant classes, optional icons, and proper escaping without repeating the same HTML in every template.
ACF button link component — The Code
This ACF button link component snippet renders ACF link fields safely — handles missing URLs, blank targets, and accessible link text.
We’ll build this in 5 small steps. Each step adds one new idea — starting from raw ACF Link output scattered across your theme, up to a complete component that renders any button with variant classes, optional icons, and full attribute escaping.
Step 1: The problem. This is what most themes look like — get_field( 'hero_button' ) followed by manual if ( $cta ) guards and inline HTML in every template. No variant system. Inconsistent class names. Icons copy-pasted as raw SVG strings. After two sprints, you have 15 templates with slightly different button markup and no way to change all primary buttons at once.
// ACF button link component — production-tested PHP snippet by Mosharaf Hossain
<?php
// BAD: ACF Link raw output in every template.
// No fallback. Unsafe attributes. Icons copy-pasted everywhere.
$cta = get_field( 'hero_button' ); // ACF field type: Link
if ( $cta ) : ?>
<a href="<?php echo $cta['url']; ?>"
target="<?php echo $cta['target']; ?>"
class="btn btn--primary">
<?php echo $cta['title']; ?>
</a>
<?php endif;
// In two months you will have 15 templates with
// slightly different class names and no consistent icon pattern.
Step 2: A basic wrapper. theme_btn() reads an ACF Link field by name and returns a safe <a> tag — or an empty string if the field isn’t populated. Every attribute is escaped: the URL through esc_url(), the label through esc_html(), and the target through esc_attr(). If the content editor leaves the Link field empty, your template renders nothing instead of a broken anchor tag.
// ACF button link component
<?php
/**
* Step 2 — Basic wrapper with null safety and attribute escaping.
*
* New in this step:
* theme_btn() reads an ACF Link field by name and returns safe markup.
* If the field is empty, it returns an empty string — no broken anchors.
* Every attribute is escaped: esc_url(), esc_attr(), esc_html().
*/
function theme_btn( string $field, string $default_label = 'Learn more' ): string {
$link = get_field( $field );
if ( empty( $link['url'] ) || empty( $link['title'] ) ) {
return '';
}
$url = esc_url( $link['url'] );
$title = esc_html( $link['title'] );
$target = ! empty( $link['target'] ) ? esc_attr( $link['target'] ) : '_self';
return sprintf(
'<a href="%s" target="%s" class="btn">%s</a>',
$url, $target, $title
);
}
Step 3: Add a variant system. The basic wrapper always outputs class="btn". Real themes need visual variants — a filled primary button for CTAs, a bordered outline button for secondary actions, and a text-only ghost button for navigation. The $args['variant'] parameter maps to a BEM modifier class (btn--primary, btn--secondary, btn--ghost, btn--outline). Invalid values are caught and fall back to primary so a typo doesn’t break your styles.
// ACF button link component
<?php
/**
* Step 3 — Variant system: primary, secondary, ghost, outline.
*
* New in this step:
* $variant controls the BEM modifier class on the <a> tag.
* Valid variants: primary | secondary | ghost | outline.
* Invalid values fall back to the default variant (primary).
*/
function theme_btn( string $field, array $args = [] ): string {
$args = wp_parse_args( $args, [
'default_label' => 'Learn more',
'variant' => 'primary',
] );
$link = get_field( $field );
if ( empty( $link['url'] ) || empty( $link['title'] ) ) {
return '';
}
$allowed = [ 'primary', 'secondary', 'ghost', 'outline' ];
$variant = in_array( $args['variant'], $allowed, true )
? $args['variant'] : 'primary';
$url = esc_url( $link['url'] );
$title = esc_html( $link['title'] );
$target = ! empty( $link['target'] ) ? esc_attr( $link['target'] ) : '_self';
return sprintf(
'<a href="%s" target="%s" class="btn btn--%s">%s</a>',
$url, $target, $variant, $title
);
}
Step 4: Add optional SVG icons. Buttons often need arrows, external-link indicators, or download icons. Instead of hard-coding SVG strings into every template, pass them through $args['icon']. The icon slot accepts raw SVG markup — typically pulled from a theme partial — and positions it before or after the label text. The icon is wrapped in aria-hidden="true" because the label already provides accessible text.
// ACF button link component
<?php
/**
* Step 4 — Inline SVG icons positioned before or after the label.
*
* New in this step:
* $args['icon'] accepts raw SVG markup (e.g. from get_template_part).
* $args['icon_position'] controls placement: before | after.
* A <span class="btn__label"> wraps the title so icons sit outside it.
* You'll find these additions integrated in the final Step 5 code.
*/
// ACF field type: Link — your CTA link.
// Pass an raw SVG string you assembled from an icon partial.
$arrow = '<svg width="16" height="16" viewBox="0 0 16 16" ' .
'fill="none" aria-hidden="true">' .
'<path d="M6 3l5 5-5 5" stroke="currentColor" ' .
'stroke-width="2" stroke-linecap="round"/></svg>';
echo theme_btn( 'cta_link', [
'default_label' => 'Get started',
'variant' => 'primary',
'icon' => $arrow,
'icon_position' => 'after',
] );
// Renders: <a href="..." class="btn btn--primary">
// <span class="btn__label">Get started</span>
// <span class="btn__icon btn__icon--after">…SVG…</span>
// </a>
Step 5: The full production component. All parameters in one signature — field name, variant, icon with position, and extra CSS classes. The function handles null-safety, escaping, variant validation, icon placement order, and the rel="noopener" attribute on externally-opening links. Every button in your theme is now one function call.
// ACF button link component
<?php
/**
* Reusable ACF Link Button Component
*
* Place in inc/theme-btn.php and require from functions.php.
* One function call renders a fully escaped, variant-styled button
* with optional inline SVG icons from any ACF Link field.
*
* @param string $field ACF Link field name.
* @param array $args {
* @type string $default_label Fallback label if field is empty.
* @type string $variant primary | secondary | ghost | outline.
* @type string $icon Raw SVG markup string.
* @type string $icon_position before | after (default: 'after').
* @type string $class Additional CSS classes.
* }
* @return string Safe HTML or empty string.
*/
function theme_btn( string $field, array $args = [] ): string {
$args = wp_parse_args( $args, [
'default_label' => 'Learn more',
'variant' => 'primary',
'icon' => '',
'icon_position' => 'after',
'class' => '',
] );
$link = get_field( $field );
if ( empty( $link['url'] ) || empty( $link['title'] ) ) {
return '';
}
$allowed_variants = [ 'primary', 'secondary', 'ghost', 'outline' ];
$variant = in_array( $args['variant'], $allowed_variants, true )
? $args['variant'] : 'primary';
$url = esc_url( $link['url'] );
$title = esc_html( $link['title'] );
$target = ! empty( $link['target'] ) ? esc_attr( $link['target'] ) : '_self';
$target_attr = $target === '_blank' ? ' rel="noopener"' : '';
$class = trim( sprintf( 'btn btn--%s %s', $variant, esc_attr( $args['class'] ) ) );
$label = '<span class="btn__label">' . $title . '</span>';
$icon = '';
if ( $args['icon'] ) {
$pos = $args['icon_position'] === 'before' ? 'before' : 'after';
$icon = '<span class="btn__icon btn__icon--' . $pos . '" aria-hidden="true">'
. $args['icon'] . '</span>';
}
$parts = [];
$parts_before = ( $args['icon_position'] === 'before' ) ? $icon : $label;
$parts_after = ( $args['icon_position'] === 'before' ) ? $label : $icon;
return sprintf(
'<a href="%s" target="%s"%s class="%s">%s%s</a>',
$url, $target, $target_attr, $class, $parts_before, $parts_after
);
}
Where to put the code
Save Step 5 in inc/theme-btn.php and require it from functions.php:
require_once get_template_directory() . '/inc/theme-btn.php';
Then replace every raw ACF Link output block with echo theme_btn( 'field_name', $args ). The field name stays the same — you’re only replacing the inline HTML with a function call.
How ACF button link component Works — Why Each Layer Exists
Here is how this ACF button link component works step by step: Five layers, each solving a real friction point.
This ACF button link component implementation works as follows.
Five layers, each solving a real friction point from the previous step.
Raw ACF Link — repetition and inconsistency
Raw ACF Link output works. The problem is repetition and inconsistency. An ACF Link field returns an array — [ 'title' => '...', 'url' => '...', 'target' => '...' ] — and every template that renders a button repeats the same three lines: check the array is populated, print the href, print the target, print the title. Forgetting esc_url() on one template creates an XSS vector. Changing the primary button class requires a find-and-replace across every template file. One typo in a target attribute can break analytics tracking.
theme_btn() — null-safety and escaping in one call
theme_btn() solves null-safety and escaping in one call. The function checks that both url and title are non-empty — an ACF Link field with only a title but no URL is invalid, and vice versa. If either is missing, the function returns an empty string. The output runs through three WordPress escaping functions: esc_url() strips invalid protocols and special characters from the URL, esc_html() prevents HTML injection in the label text, and esc_attr() sanitises the target value. These three calls make the button safe regardless of what the content editor enters.
Variant system — presentation decoupled from data
The variant system decouples presentation from data. The ACF Link field stores the content — URL, label, target. The variant tells your CSS how to style it — filled background, bordered, or text-only. By limiting variants to a whitelist (primary | secondary | ghost | outline), you prevent a CSS class from breaking because someone typed "prymary". The in_array() check with a strict comparison ensures only valid modifiers reach the output.
The variant maps directly to a BEM modifier class (btn--primary), which means your CSS uses predictable selectors. You can style .btn--primary, .btn--secondary, .btn--ghost, and .btn--outline in one SCSS file and every button in the theme follows those rules.
SVG icons — inline, theme-controlled, zero-dependency
The icon system keeps SVG markup in one place — the icon partial files in your theme — and passes them as raw strings to the button component. The icon_position parameter (before | after) controls whether the icon appears to the left or right of the label. The function wraps icons in <span class="btn__icon btn__icon--{before|after}" aria-hidden="true"> so screen readers ignore them (the button label already describes the action) and your CSS can target icon spacing with .btn__icon--after { margin-left: 0.5rem; }.
The icon is a raw SVG string — not a file path. This means you control the exact markup and can pass currentColor-styled SVGs that inherit the button’s text colour on hover. This is deliberate: icon font libraries add CSS weight and framework dependency. Inline SVGs styled with stroke="currentColor" need zero additional CSS.
Production component — all parameters, one call
The production component combines all parameters into one signature. wp_parse_args() merges the caller’s array with defaults so optional arguments don’t need to be passed every time. The $target_attr variable adds rel="noopener" when the link opens in a new tab — this is a security best practice that prevents the opened page from accessing window.opener. The $class variable concatenates the base class, the variant modifier, and any additional classes from the caller. The icon and label are ordered based on icon_position using two variables ($parts_before, $parts_after) that hold whichever component goes first.
ACF button link component — Setup & Integration
To integrate this ACF button link component into your project: The component is one PHP file. Wire it into your theme, create your ACF Link fields, and replace your raw button markup.
This ACF button link component implementation works as follows.
The component is a single PHP file. Wire it into your theme, create your ACF Link fields, and replace your raw button markup.
Create inc/theme-btn.php and require it
Create inc/theme-btn.php with the Step 5 code. In functions.php, add the require statement at the top of the file:
require_once get_template_directory() . '/inc/theme-btn.php';
This makes theme_btn() available in every template file.
Create your ACF Link fields
Create your ACF Link fields. In your field group, add a field with Field Type → Link. Set the Return Format to Link Array — this is the format theme_btn() expects. Name your fields consistently: hero_cta, section_link, footer_button. The name becomes the first argument to theme_btn().
Create icon partial files
Create icon partials. Each SVG lives in a small PHP file under template-parts/icons/. For example, template-parts/icons/arrow-right.php:
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">n <path d="M6 3l5 5-5 5" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>n</svg>
Capture the SVG output in your template using output buffering or a helper, then pass it to theme_btn():
// In your template:nob_start();nget_template_part( 'template-parts/icons/arrow-right' );n$arrow_icon = ob_get_clean();nnecho theme_btn( 'hero_cta', [n 'variant' => 'primary',n 'icon' => $arrow_icon,n 'icon_position' => 'after',n] );
Replace raw button markup with theme_btn()
Replace your raw button markup. In hero.php, change:
// Before — 8 lines of manual HTML.n$cta = get_field( 'hero_button' );nif ( $cta ):n ?>n <a href="<?php echo esc_url( $cta['url'] ); ?>"n target="<?php echo esc_attr( $cta['target'] ?: '_self' ); ?>"n class="btn btn--primary"><?php echo esc_html( $cta['title'] ); ?></a>n <?phpnendif;nn// After — 1 line.necho theme_btn( 'hero_button', [ 'variant' => 'primary' ] );
Repeat for every template that renders an ACF Link field. Your templates shrink and every button shares the same escaping, validation, and markup.
Making This ACF button link component Your Own
Customise this ACF button link component to match your specific needs: The component is a pattern, not a closed set. Add size variants, alignment wrappers, hardcoded-link helpers, or button groups.
Add a size variant (sm / md / lg)
Add a size variant. If your design system has small, regular, and large buttons, add a size parameter that appends a second BEM modifier:
$valid_sizes = [ 'sm', 'md', 'lg' ];n$size = in_array( $args['size'], $valid_sizes, true ) ? $args['size'] : 'md';nn// In the sprintf, change the class line to:n$class = trim( sprintf( 'btn btn--%s btn--%s %s', $variant, $size, esc_attr( $args['class'] ) ) );
The same whitelist pattern keeps invalid values from leaking into your output. Your CSS targets .btn--sm, .btn--md, and .btn--lg.
Add an alignment wrapper for flex layouts
Add a wrapper element for alignment. If your buttons need to be centered or aligned to the right in a flex container, add a wrapper parameter that optionally wraps the anchor tag:
if ( $args['wrapper'] ) {n return sprintf( '<div class="btn-wrapper btn-wrapper--%s">%s</div>',n esc_attr( $args['wrapper_align'] ?? 'left' ),n theme_btn( $field, array_merge( $args, [ 'wrapper' => false ] ) )n );n}
This keeps the button component focused on rendering the anchor tag while the wrapper handles layout concerns.
Add theme_btn_manual() for non-ACF links
Add a theme_btn_manual() helper for non-ACF links. Not every button comes from an ACF field — sometimes you have a hardcoded URL or a WordPress permalink:
function theme_btn_manual( string $url, string $label, array $args = [] ): string {n $args = wp_parse_args( $args, [n 'variant' => 'primary',n 'icon' => '',n 'icon_position' => 'after',n 'class' => '',n 'target' => '_self',n ] );nn $link = [n 'url' => $url,n 'title' => $label,n 'target' => $args['target'],n ];nn // Temporarily override the ACF field lookup —n // the rest of theme_btn stays the same.n $original = get_field( '_placeholder_' );n add_filter( 'acf/load_value', function( $value ) use ( $link ) {n static $called = 0;n return $called++ ? $value : $link;n } );n $output = theme_btn( '_placeholder_', $args );n remove_all_filters( 'acf/load_value' );n return $output;n}
This approach reuses all the escaping, variant logic, and icon rendering from theme_btn() without duplicating code. Alternatively, refactor the internal rendering into a shared private helper.
Create theme_btn_group() for side-by-side buttons
Create a button group component for side-by-side buttons. Hero sections often have two buttons — a primary CTA and a secondary link. Write a theme_btn_group() that accepts two ACF Link field names and renders both inside a flex wrapper:
function theme_btn_group( array $buttons, array $args = [] ): string {n $args = wp_parse_args( $args, [n 'gap' => 'md',n 'align' => 'left',n 'class' => '',n ] );nn $html = '';n foreach ( $buttons as $btn ) {n $html .= theme_btn(n $btn['field'] ?? '',n $btn['args'] ?? []n );n }nn if ( ! $html ) return '';nn return sprintf(n '<div class="btn-group btn-group--%s btn-group--%s %s">%s</div>',n esc_attr( $args['gap'] ), esc_attr( $args['align'] ),n esc_attr( $args['class'] ), $htmln );n}nn// Usage:necho theme_btn_group( [n [ 'field' => 'primary_cta', 'args' => [ 'variant' => 'primary' ] ],n [ 'field' => 'secondary_cta', 'args' => [ 'variant' => 'outline' ] ],n], [ 'gap' => 'md', 'align' => 'center' ] );
3 things that will silently break:
1. ACF Link Return Format mismatch. theme_btn() expects the Link field to return a Link Array — an associative array with url, title, and target keys. If your field is set to return a URL string, $link['url'] will be null and the function will return an empty string for every button. Fix: in ACF, set the Link field Return Format → Link Array.
2. Passing unescaped SVG strings. The icon parameter accepts raw SVG markup. If you construct the SVG dynamically — e.g., passing user input into an SVG attribute — you introduce an XSS vector through the icon slot. Only pass SVGs from trusted theme partials or sources you control. Never build an SVG from user-submitted text.
3. Forgetting the icon partial files. theme_btn() doesn’t load icon files — it expects a pre-assembled SVG string. If you reference a partial that doesn’t exist, get_template_part() silently returns nothing, output buffering captures an empty string, and your button renders without an icon. Test that your icon partials actually output SVG markup before passing them to the component.
Performance notes:
- ACF caches internally: Every
get_field()call reads from ACF’s in-memory cache after the first access for that post during the current request. Rendering three buttons from the same ACF Link field on one page makes one database query, not three. - Output buffering is near-zero cost: Capturing SVG partials with
ob_start()/ob_get_clean()adds negligible overhead — PHP’s output buffer is a memory buffer, not a file write. For buttons that don’t use icons, skip the buffering entirely by checking theiconargument first. - Inline SVGs add no HTTP requests: Unlike
<img src="icon.svg">, inline SVGs are part of the HTML payload. No extra network round-trips. Combine this with your HTML caching layer (page cache, CDN) and button icons cost zero additional bytes after the first render.

No comments yet. Be the first.