Infinite Scroll Migration
When to Use
Migrate "Load More" buttons or infinite scroll patterns that append new content to a list. Common in content listings, search results, and feeds.
Steps
- Use route parameter for page number — Track pagination state
- Replace
#ajaxbutton with HTMX button — Configure to append content - Use
swap('beforeend')for appending — Add new items to list - For scroll-triggered loading — Use sentinel element with
trigger('revealed') - Update button to point to next page — Increment page parameter
BEFORE: AJAX
$form['load_more'] = [
'#type' => 'button',
'#value' => t('Load More'),
'#ajax' => [
'callback' => '::loadMoreCallback',
'wrapper' => 'content-list',
'method' => 'append',
],
];
$form['content_list'] = [
'#type' => 'container',
'#attributes' => ['id' => 'content-list'],
];
public function loadMoreCallback(array &$form, FormStateInterface $form_state) {
$page = $form_state->get('page') + 1;
$form_state->set('page', $page);
$items = $this->loadItems($page);
return [
'#theme' => 'item_list',
'#items' => $items,
];
}
AFTER: HTMX (Button-Triggered)
use Drupal\Core\Htmx\Htmx;
use Drupal\Core\Url;
$page = $form_state->getValue('page', 0);
$nextPage = $page + 1;
$form['load_more'] = [
'#type' => 'html_tag',
'#tag' => 'button',
'#value' => t('Load More'),
'#attributes' => ['type' => 'button'],
];
(new Htmx())
->get(Url::fromRoute('my_module.load_items', ['page' => $nextPage]))
->onlyMainContent()
->select('.item-list')
->target('#content-list')
->swap('beforeend') // Append to end of container
->applyTo($form['load_more']);
$form['content_list'] = [
'#type' => 'container',
'#attributes' => ['id' => 'content-list'],
'items' => [
'#theme' => 'item_list',
'#items' => $this->loadItems($page),
'#attributes' => ['class' => ['item-list']],
],
];
AFTER: HTMX (Scroll-Triggered)
use Drupal\Core\Htmx\Htmx;
use Drupal\Core\Url;
$page = $form_state->getValue('page', 0);
$nextPage = $page + 1;
$form['content_list'] = [
'#type' => 'container',
'#attributes' => ['id' => 'content-list'],
'items' => [
'#theme' => 'item_list',
'#items' => $this->loadItems($page),
'#attributes' => ['class' => ['item-list']],
],
];
// Sentinel element at bottom of list triggers load when visible
$form['sentinel'] = [
'#type' => 'html_tag',
'#tag' => 'div',
'#attributes' => ['id' => 'load-sentinel'],
'#value' => '', // Can add loading indicator
];
(new Htmx())
->get(Url::fromRoute('my_module.load_items', ['page' => $nextPage]))
->trigger('revealed') // Trigger when element enters viewport
->onlyMainContent()
->select('.item-list')
->target('#content-list')
->swap('beforeend')
->applyTo($form['sentinel']);
Reference: HTMX revealed trigger and swap strategies
Common Mistakes
- Using
'method' => 'append'→ HTMX usesswap('beforeend')to append content inside an element - Storing page in form state → Use route parameter for the load endpoint:
Url::fromRoute('my_module.load', ['page' => $page]) - Not using
revealedtrigger for infinite scroll → Thetrigger('revealed')fires when element enters viewport, perfect for scroll-triggered loading - Updating the wrong element →
target()should point to the list container,select()should extract just the new items from response - Not hiding sentinel after last page → Check if more items exist and conditionally include the sentinel element
See Also
- Previous: Real-Time Validation Migration
- Next: Dynamic Field Addition Migration
- Reference: HTMX
revealedtrigger in HTMX documentation