Infinite Scroll

A single <div lvt-scroll-sentinel> at the end of the list is watched by the client's IntersectionObserver. When it scrolls into view the client dispatches the load_more action on its own — no client JS to wire up. The handler is nearly identical to Click To Load — it just pages a larger dataset; only the trigger really differs. When the last page arrives, HasMore goes false and the sentinel is removed so it stops firing.

Infinite Scroll

A single <div lvt-scroll-sentinel> at the end of the list is watched by the client's IntersectionObserver. When it enters the viewport, the client dispatches load_more automatically — no client JS to wire up. Dataset has 100 items; scroll to watch pages append.

IDNameEmail
1 Row 1 row1@example.com
2 Row 2 row2@example.com
3 Row 3 row3@example.com
4 Row 4 row4@example.com
5 Row 5 row5@example.com
6 Row 6 row6@example.com
7 Row 7 row7@example.com
8 Row 8 row8@example.com
9 Row 9 row9@example.com
10 Row 10 row10@example.com
Loading more…

Template

The sentinel <div lvt-scroll-sentinel> doubles as the loading indicator; once HasMore is false it is replaced by an "End of list" note.

{{define "content"}}
<article>
    <h3>Infinite Scroll</h3>
    <p><small>A single <code>&lt;div lvt-scroll-sentinel&gt;</code> at the end of the list is watched by the client's IntersectionObserver. When it enters the viewport, the client dispatches <code>load_more</code> automatically — no client JS to wire up. Dataset has 100 items; scroll to watch pages append.</small></p>
    <table>
        <thead><tr><th>ID</th><th>Name</th><th>Email</th></tr></thead>
        <tbody>
        {{range .Items}}
        <tr data-key="{{.ID}}">
            <td>{{.ID}}</td>
            <td>{{.Name}}</td>
            <td>{{.Email}}</td>
        </tr>
        {{end}}
        </tbody>
    </table>
    {{if .HasMore}}
    <div lvt-scroll-sentinel><small aria-busy="true">Loading more…</small></div>
    {{else}}
    <p><small>End of list.</small></p>
    {{end}}
</article>
{{end}}

infinite-scroll.tmpl

Handler & state

LoadMore is dispatched automatically by the sentinel. It bumps the page, appends the next slice, and updates HasMore.

type InfiniteScrollController struct{}

// LoadMore is dispatched by the client-side IntersectionObserver when
// <div lvt-scroll-sentinel> becomes visible. Uses the larger
// infiniteScrollDataset (100 items) so the auto-pagination cascade is
// actually visible during the demo; ClickToLoad uses the 25-item
// listDataset which only needs a couple of clicks.
func (c *InfiniteScrollController) LoadMore(state InfiniteScrollState, ctx *livetemplate.Context) (InfiniteScrollState, error) {
	state.CurrentPage++
	newItems := getInfiniteScrollPage(state.CurrentPage, listPageSize)
	state.Items = append(state.Items, newItems...)
	state.HasMore = len(newItems) == listPageSize
	return state, nil
}

func infiniteScrollHandler() http.Handler {
	tmpl := newLayoutTmpl("templates/layout.tmpl", "templates/lists/infinite-scroll.tmpl")
	return tmpl.Handle(&InfiniteScrollController{}, livetemplate.AsState(&InfiniteScrollState{
		Title:       "Infinite Scroll",
		Category:    "Lists & Data",
		Items:       getInfiniteScrollPage(1, listPageSize),
		CurrentPage: 1,
		HasMore:     true,
	}))
}

handlers_lists.go:155-180

type InfiniteScrollState struct {
	Title       string
	Category    string
	Items       []Item
	CurrentPage int
	HasMore     bool
}

state_lists.go:27-34

When to use

Use Click To Load instead when the user should decide when more rows load.

source: livetemplate/docs · path: examples/patterns/templates/lists/infinite-scroll.tmpl