I ended up using ChatGPT to build a custom encoded solution.
Documenting it here incase someone else has similar issues.
In the functions.php of Bricks Child theme I exposed the product variant’s attributes to new meta keys, as well as flattening the attributes for the parent product:
/*Expose product_variation*/
// Expose variation meta fields in Bricks dynamic tags
add_filter('bricks/setup/control-options', function($options, $control) {
if ($control['id'] === 'post.meta') {
$meta_key = $control['options']['key'] ?? '';
// List of allowed meta fields to expose
$allowed_keys = [
'_sku',
'_price',
'volume_litre',
'power_output',
'weight_kg',
'shelves',
'ext_width',
'ext_depth',
'ext_height',
'int_width',
'int_depth',
'int_height',
];
if (in_array($meta_key, $allowed_keys)) {
global $post;
if ($post && $post->post_type === 'product_variation') {
$options['value'] = get_post_meta($post->ID, $meta_key, true);
}
}
}
return $options;
}, 10, 2);
// Optional: Make sure product_variation is queryable by Bricks
add_action('init', function() {
global $wp_post_types;
if (isset($wp_post_types['product_variation'])) {
$wp_post_types['product_variation']->public = true;
$wp_post_types['product_variation']->show_ui = true;
$wp_post_types['product_variation']->show_in_menu = true;
$wp_post_types['product_variation']->show_in_rest = true;
$wp_post_types['product_variation']->exclude_from_search = false;
$wp_post_types['product_variation']->has_archive = true;
}
}, 20);
/*Add Flattened Variation Data to Parent Product*/
add_action('save_post_product', function($post_id) {
if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
$product = wc_get_product($post_id);
if (! $product || ! $product->is_type('variable')) return;
$variation_ids = $product->get_children();
$volumes_raw = [];
$volumes_labelled = [];
foreach ($variation_ids as $variation_id) {
$volume = get_post_meta($variation_id, 'attribute_pa_volume', true);
if ($volume && is_numeric($volume)) {
$volumes_raw[] = $volume;
$volumes_labelled[] = $volume . 'L';
}
}
$volumes_raw = array_unique($volumes_raw);
$volumes_labelled = array_unique($volumes_labelled);
update_post_meta($post_id, 'all_variant_volumes', implode(', ', $volumes_labelled));
update_post_meta($post_id, 'all_variant_volumes_raw', implode(', ', $volumes_raw));
});
Now that I have the variant’s attributes exposed, I moved away from using ACF Repeater.
For the interior loop I am using custom php.
return [
'post_type' => 'product_variation',
'post_status' => 'publish',
'posts_per_page' => -1,
'post_parent' => get_the_ID(), // Works inside Bricks nested loops
'meta_key' => 'attribute_pa_volume', // The meta field to order by
'orderby' => 'meta_value_num', // Numeric ordering
'order' => 'ASC', // Change to 'DESC' for reverse order
];
And a custom code block to house the element, which is built with php to pull the exposed attributes.
<?php
$volume = get_post_meta(get_the_ID(), 'attribute_pa_volume', true);
$sku = get_post_meta(get_the_ID(), '_sku', true);
$price = get_post_meta(get_the_ID(), '_price', true);
$price = is_numeric($price) ? $price : ''; // Ensure safe value
$parent_id = wp_get_post_parent_id(get_the_ID());
$parent_url = get_permalink($parent_id);
// Gather all filterable attributes
$attrs = [
'power_output' => get_post_meta(get_the_ID(), 'attribute_pa_power-output', true),
'weight' => get_post_meta(get_the_ID(), 'attribute_pa_weight', true),
'shelves' => get_post_meta(get_the_ID(), 'attribute_pa_shelves', true),
'external_width' => get_post_meta(get_the_ID(), 'attribute_pa_external-width', true),
'external_depth' => get_post_meta(get_the_ID(), 'attribute_pa_external-depth', true),
'external_height' => get_post_meta(get_the_ID(), 'attribute_pa_external-height', true),
'internal_width' => get_post_meta(get_the_ID(), 'attribute_pa_internal-width', true),
'internal_depth' => get_post_meta(get_the_ID(), 'attribute_pa_internal-depth', true),
'internal_height' => get_post_meta(get_the_ID(), 'attribute_pa_internal-height', true),
'price' => $price,
];
if ($volume !== '' && $sku && $parent_url) {
echo '<div class="variant-item"';
echo ' data-volume="' . esc_attr($volume) . '"';
foreach ($attrs as $key => $val) {
echo ' data-' . esc_attr($key) . '="' . esc_attr($val) . '"';
}
echo '>';
echo '<a href="' . esc_url($parent_url . '#' . $sku) . '">' . esc_html($volume) . 'L</a>';
echo '</div>';
}
?>

I am still using WP Grid Builder to filter the parent products.
I’m then using JS, housed in Body (footer), to check the value applied to the WPGB filter facet then hide the variant’s elements that sit outside of the range.
<script>
function parseRangeFromURL(param) {
const params = new URLSearchParams(window.location.search);
const raw = params.get(param);
if (!raw) return [NaN, NaN];
const [min, max] = raw.split(',').map(Number);
return [min, max];
}
function parseRangeFromFacetDOM(facetName) {
const sliders = document.querySelectorAll(`input.wpgb-range[name="${facetName}[]"]`);
if (sliders.length < 2) return [NaN, NaN];
return [
parseFloat(sliders[0].value),
parseFloat(sliders[1].value),
];
}
function getActiveFilters() {
const filters = {};
const keys = [
'volume', 'power_output', 'weight', 'shelves',
'external_width', 'external_depth', 'external_height',
'internal_width', 'internal_depth', 'internal_height',
'price',
];
keys.forEach(key => {
let [min, max] = parseRangeFromURL(`_${key}`);
if (isNaN(min) || isNaN(max)) {
[min, max] = parseRangeFromFacetDOM(key);
}
filters[key] = [min, max];
});
return filters;
}
function filterVariants() {
const filters = getActiveFilters();
document.querySelectorAll('.variant-item').forEach(variant => {
let visible = true;
for (const key in filters) {
const val = parseFloat(variant.dataset[key]);
const [min, max] = filters[key];
if (!isNaN(min) && !isNaN(max)) {
if (isNaN(val) || val < min || val > max) {
visible = false;
break;
}
}
}
variant.style.display = visible ? '' : 'none';
});
document.querySelectorAll('.product-card').forEach(card => {
const visibleVariants = card.querySelectorAll('.variant-item:not([style*="display: none"])').length;
card.style.display = visibleVariants > 0 ? '' : 'none';
});
}
function observeDomUpdates() {
const observer = new MutationObserver(() => {
setTimeout(filterVariants, 0); // Wait for DOM to rebuild
});
observer.observe(document.body, { childList: true, subtree: true });
}
document.addEventListener('DOMContentLoaded', () => {
filterVariants();
observeDomUpdates();
});
</script>
I believe this is everything I’m using to forcibly solve the problem.
ChatGPT pulled a lot of the weight here, but I wouldn’t have gotten anywhere close to this on my own.