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.

PHP component Production-tested ~5 min setup ACF Link field Intermediate WP 6.0+ · ACF 6.0+ Updated May 15, 2026
TL;DR

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.

01 — Building it step by step

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.

template-parts/sections/step-1-raw.php
php
// 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.

template-parts/sections/step-2-wrapper.php
php
// 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.

template-parts/sections/step-3-variant.php
php
// 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.

template-parts/sections/step-4-icon.php
php
// 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.

inc/theme-btn.php
php
// 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.

02 — How it works

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.

1

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.

2

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.

3

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.

4

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.

5

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.

03 — Setup & integration

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.

wp-content/themes/your-theme/
│   ├── inc/
│   │   └── theme-btn.php
│   ├── template-parts/
│   │   ├── icons/
│   │   │   ├── arrow-right.php
│   │   │   ├── external-link.php
│   │   │   └── download.php
│   │   ├── hero.php
│   │   └── cta-banner.php
│   ├── acf-json/
│   │   ├── group_page_hero.json
│   │   └── group_cta_section.json
    └── functions.php
1

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.

2

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().

3

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] );
4

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.

04 — Making it your own

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.

1

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.

2

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.

3

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.

4

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 the icon argument 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.
0 comments

No comments yet. Be the first.

Leave a Reply

Table of Contents

ACF button link component — Code Snippet

ACF button link component — code snippet by Mosharaf Hossain

This ACF button link component is a production-tested PHP snippet for WordPress developers. See the ACF Link field documentation for related official documentation.

The ACF button link component snippet creates a reusable helper function that renders a button or text link from an ACF Link field — with a safe href, correct target attribute, automatic rel=”noopener” for external links, and a configurable CSS class — without duplicating the field-reading and HTML-building logic across every template that shows a call-to-action button or navigation link.

The ACF Link field returns an array with three keys: url, title, and target. The target key is an empty string for same-tab links and “_blank” for new-tab links. Without a helper, every template that shows a button must check if the field is set, read all three keys, null-check the url, conditionally add a target attribute, and remember to add rel=”noopener noreferrer” to prevent tab-napping attacks when target is _blank. A five-line repeated pattern becomes a bug surface where one template adds the security attribute and another forgets it.

The helper function takes the ACF field value, a CSS class name, and an optional HTML attribute override array. It null-checks the value, sanitises the URL with esc_url, escapes the title with esc_html, and builds the target and rel attributes from the target key. Internal links with no target=”_blank” get no rel attribute. External links automatically receive rel=”noopener noreferrer”. If the url is empty after sanitisation, the function returns an empty string so no broken anchor tag reaches the DOM.

The component is called from any template with one line, passing the ACF field value and a CSS class string. The class name is injected into the anchor element’s class attribute. Additional attributes such as aria-label or data attributes can be passed as the third argument array and are rendered with esc_attr for safe output.

Place the function in inc/acf-helpers.php alongside other ACF rendering utilities. Tested on WordPress 6.0+ with ACF 6.0+. Works with both ACF Free and ACF Pro. The Link field type is available in ACF 5.8+.

Browse all ACF PHP snippets at Code Snippets or see the ACF Image Field Renderer for safe image output from ACF. This component pattern is used across the Vitamines project for every call-to-action button and navigation link rendered from ACF fields.

Related: ACF Image Field Renderer — and browse the full code snippet library.

Chat on WhatsApp