Post Type Meta Boxes Repeater

PHP Repeater

/**
 * 1. Register the meta box and enqueue scripts
 */
function register_repeater_meta_box() {
    add_meta_box(
        'repeater_meta_box',
        'Repeatable Items',
        'render_repeater_meta_box',
        'your_post_type'
    );
    
    // Only load on our post edit screen
    global $pagenow;
    if ($pagenow === 'post.php' || $pagenow === 'post-new.php') {
        wp_enqueue_script('jquery-ui-sortable');
        wp_enqueue_script('repeater-js', get_template_directory_uri() . '/js/repeater.js', ['jquery'], '1.0', true);
    }
}
add_action('add_meta_boxes', 'register_repeater_meta_box');

/**
 * 2. Render the repeater field container
 */
function render_repeater_meta_box($post) {
    wp_nonce_field('repeater_nonce', 'repeater_nonce');
    
    // Get existing items
    $items = get_post_meta($post->ID, '_repeater_field', true);
    $items = is_array($items) ? $items : [['title' => '', 'description' => '']];
    
    echo '<div class="repeater-container" data-template="repeater-template">';
    
    foreach ($items as $index => $item) {
        echo '<div class="repeater-row">';
        echo '<input type="text" name="repeater_field['.$index.'][title]" value="'.esc_attr($item['title']).'" placeholder="Title">';
        echo '<textarea name="repeater_field['.$index.'][description]" placeholder="Description">'.esc_textarea($item['description']).'</textarea>';
        echo '<button class="remove-row">Remove</button>';
        echo '</div>';
    }
    
    echo '</div>';
    echo '<button class="add-row">Add Item</button>';
    
    // Template for new rows (hidden by default)
    echo '<script type="text/template" id="repeater-template">';
    echo '<div class="repeater-row">';
    echo '<input type="text" name="repeater_field[__INDEX__][title]" value="" placeholder="Title">';
    echo '<textarea name="repeater_field[__INDEX__][description]" placeholder="Description"></textarea>';
    echo '<button class="remove-row">Remove</button>';
    echo '</div>';
    echo '</script>';
}

/**
 * 3. Save the repeater data
 */
function save_repeater_meta_box($post_id) {
    if (!isset($_POST['repeater_nonce']) || !wp_verify_nonce($_POST['repeater_nonce'], 'repeater_nonce')) {
        return;
    }
    
    if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
    if (!current_user_can('edit_post', $post_id)) return;
    
    // Sanitize and save
    if (isset($_POST['repeater_field'])) {
        $clean_items = [];
        
        foreach ($_POST['repeater_field'] as $item) {
            $clean_items[] = [
                'title' => sanitize_text_field($item['title']),
                'description' => wp_kses_post($item['description'])
            ];
        }
        
        update_post_meta($post_id, '_repeater_field', $clean_items);
    } else {
        delete_post_meta($post_id, '_repeater_field');
    }
}
add_action('save_post', 'save_repeater_meta_box');

Javascript

jQuery(document).ready(function($) {
    $('.repeater-container').on('click', '.remove-row', function(e) {
        e.preventDefault();
        $(this).closest('.repeater-row').remove();
    });
    
    $('.add-row').on('click', function(e) {
        e.preventDefault();
        var container = $(this).prev('.repeater-container');
        var template = container.data('template');
        var index = container.find('.repeater-row').length;
        var html = $('#' + template).html().replace(/__INDEX__/g, index);
        container.append(html);
    });
    
    // Make rows sortable
    $('.repeater-container').sortable({
        handle: '.repeater-row',
        axis: 'y'
    });
});

CSS to make it pretty

.repeater-container {
    margin-bottom: 15px;
}

.repeater-row {
    padding: 15px;
    margin-bottom: 10px;
    border: 1px solid #ddd;
    background: #f9f9f9;
    position: relative;
}

.repeater-row input[type="text"],
.repeater-row textarea {
    width: 100%;
    margin-bottom: 10px;
}

.remove-row {
    color: #a00;
    cursor: pointer;
    background: none;
    border: none;
    padding: 0;
}

.add-row {
    background: #0073aa;
    color: white;
    border: none;
    padding: 5px 10px;
    cursor: pointer;
}

Front end usage note

$items = get_post_meta(get_the_ID(), '_repeater_field', true);
if ($items) {
    echo '<ul class="repeater-items">';
    foreach ($items as $item) {
        echo '<li>';
        echo '<h3>'.esc_html($item['title']).'</h3>';
        echo '<div>'.wp_kses_post($item['description']).'</div>';
        echo '</li>';
    }
    echo '</ul>';
}