Theme
Version
GitHub PyPI Discord
On this page

Infinite Scroll

CInfiniteScroll owns the request boundary around results. Your application still owns the records, request, response, item identity, and rerender.

Keep a server fallback

Set action_name inside a form to render a named Load more submit button. It uses formnovalidate, so unrelated incomplete fields do not block pagination.

Load another result page
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class InfiniteScrollAtAGlance(Component):
    template = """
      <section x-data>
        <form @submit.prevent>
          <c-CInfiniteScroll
            aria_label="Activity feed"
            action_name="feed_action"
            c-auto="False"
            $c-props="{loading, hasMore, onLoadMore: loadNextPage}"
          >
            <ol>
              <li>Created the project</li>
              <li>Invited the design team</li>
              <li>Published the brief</li>
              <template x-for="activity in moreActivities" :key="activity.id">
                <li x-text="activity.label"></li>
              </template>
            </ol>
          </c-CInfiniteScroll>
        </form>
        <output aria-live="polite" x-text="`Loaded ${3 + moreActivities.length} activities`">
          Loaded 3 activities
        </output>
      </section>
    """

    js = """
      $component(({ scope }) => {
        const pages = [
          [
            { id: 4, label: 'Received legal approval' },
            { id: 5, label: 'Scheduled the launch' },
          ],
          [
            { id: 6, label: 'Opened early access' },
            { id: 7, label: 'Collected the first responses' },
          ],
        ];
        scope.moreActivities = [];
        scope.loading = false;
        scope.hasMore = true;
        let page = 0;
        scope.loadNextPage = detail => {
          // This static preview handles the named action locally. A server page
          // lets the submit continue and returns the next keyed result page.
          detail.sourceEvent?.preventDefault();
          if (scope.loading || !scope.hasMore) return;
          scope.loading = true;
          return new Promise(resolve => setTimeout(() => {
            scope.moreActivities.push(...pages[page]);
            page += 1;
            scope.hasMore = page < pages.length;
            scope.loading = false;
            resolve();
          }, 220));
        };
      });
    """


preview = InfiniteScrollAtAGlance()
preview  # noqa: B018

The preview intercepts the named action and appends two bounded dummy pages so you can exercise the complete state change here. In an application, let the named submit continue and return the next keyed result page from the server.

Keep loading automatically

Pass onLoadMore through $c-props. When the sentinel reaches root_margin, the callback receives {reason: 'intersection', sourceEvent: null}. Button activation uses button or retry and includes the native event.

Observe the result boundary
Show code
# ruff: noqa: E501 - embedded Citry template attributes remain readable as authored HTML

import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class InfiniteScrollAutomatic(Component):
    template = """
      <section x-data>
        <p>Scroll to the end of the clipped result feed.</p>
        <c-CInfiniteScroll
          aria_label="Search results"
          c-style="{'max-block-size': '12rem', 'overflow': 'auto', 'overflow-anchor': 'none', 'padding-inline-end': '0.25rem'}"
          $c-props="{loading, hasMore, onLoadMore: loadNextPage}"
        >
          <ol>
            <li>Search result 1</li><li>Search result 2</li><li>Search result 3</li><li>Search result 4</li>
            <li>Search result 5</li><li>Search result 6</li><li>Search result 7</li><li>Search result 8</li>
            <template x-for="result in moreResults" :key="result.id">
              <li x-text="result.label"></li>
            </template>
          </ol>
        </c-CInfiniteScroll>
        <output aria-live="polite" x-text="`Loaded ${8 + moreResults.length} results`">
          Loaded 8 results
        </output>
      </section>
    """

    js = """
      $component(({ scope }) => {
        scope.moreResults = [];
        scope.loading = false;
        scope.hasMore = true;
        let nextResult = 9;
        scope.loadNextPage = () => {
          if (scope.loading) return;
          scope.loading = true;
          return new Promise(resolve => setTimeout(() => {
            const page = Array.from({ length: 4 }, () => {
              const id = nextResult++;
              return { id, label: `Search result ${id}` };
            });
            scope.moreResults.push(...page);
            scope.loading = false;
            resolve();
          }, 260));
        };
      });
    """


preview = InfiniteScrollAutomatic()
preview  # noqa: B018

Update loading, error, or hasMore from application state. Only one request may be in progress at a time. A state change, nested content append, or returned Promise settling releases that request lock and permits a later request.

The automatic preview uses a clipped result feed. Scroll that feed to its end to append another four generated results. It deliberately has no final page, so every fresh trip to the end loads more. Production code sets hasMore=False when its data source returns no continuation.

Compose with Virtual List

Infinite Scroll answers when to load. Virtual List answers which known items to render. Nest either component in the default slot without sharing their state machines.

Load outside a virtualized collection
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class InfiniteScrollVirtualList(Component):
    template = """
      <section x-data>
        <c-CInfiniteScroll
          aria_label="Audit log"
          c-auto="False"
          $c-props="{loading, hasMore, onLoadMore: loadSnapshot}"
        >
          <div x-bind:hidden="expanded">
            <c-CVirtualList aria_label="Loaded audit records" c-viewport_size="180">
              <c-CVirtualListItem item_key="event-1">Signed in</c-CVirtualListItem>
              <c-CVirtualListItem item_key="event-2">Changed billing contact</c-CVirtualListItem>
              <c-CVirtualListItem item_key="event-3">Exported report</c-CVirtualListItem>
            </c-CVirtualList>
          </div>
          <div hidden x-bind:hidden="!expanded">
            <c-CVirtualList aria_label="Loaded audit records" c-viewport_size="180">
              <c-CVirtualListItem item_key="event-1">Signed in</c-CVirtualListItem>
              <c-CVirtualListItem item_key="event-2">Changed billing contact</c-CVirtualListItem>
              <c-CVirtualListItem item_key="event-3">Exported report</c-CVirtualListItem>
              <c-CVirtualListItem item_key="event-4">Created an API token</c-CVirtualListItem>
              <c-CVirtualListItem item_key="event-5">Updated tax details</c-CVirtualListItem>
              <c-CVirtualListItem item_key="event-6">Invited a reviewer</c-CVirtualListItem>
            </c-CVirtualList>
          </div>
        </c-CInfiniteScroll>
        <output aria-live="polite" x-text="expanded ? 'Showing 6 audit records' : 'Showing 3 audit records'">
          Showing 3 audit records
        </output>
      </section>
    """

    js = """
      $component(({ scope }) => {
        scope.expanded = false;
        scope.loading = false;
        scope.hasMore = true;
        scope.loadSnapshot = () => {
          if (scope.loading || !scope.hasMore) return;
          scope.loading = true;
          return new Promise(resolve => setTimeout(() => {
            scope.expanded = true;
            scope.hasMore = false;
            scope.loading = false;
            resolve();
          }, 220));
        };
      });
    """


preview = InfiniteScrollVirtualList()
preview  # noqa: B018

This static preview swaps from a three-record server snapshot to a six-record snapshot. A real owner returns the newly rendered keyed collection.

Show errors and retry

Set error=True after a failed request. The same stable action becomes Try again, and its callback reason becomes retry. Automatic observation pauses until that explicit retry or another state change clears the error.

Offer a retry path
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class InfiniteScrollErrorRetry(Component):
    template = """
      <section x-data>
        <c-CInfiniteScroll
          aria_label="Orders"
          c-auto="False"
          $c-props="{loading, error, hasMore, onLoadMore: retryPage}"
        >
          <ul>
            <li>Order #1042</li>
            <li>Order #1041</li>
            <template x-for="order in recoveredOrders" :key="order.id">
              <li x-text="order.label"></li>
            </template>
          </ul>
        </c-CInfiniteScroll>
        <output aria-live="polite" x-text="recovered ? 'Orders recovered' : 'Last request failed'">
          Last request failed
        </output>
      </section>
    """

    js = """
      $component(({ scope }) => {
        scope.recoveredOrders = [];
        scope.loading = false;
        scope.error = true;
        scope.hasMore = true;
        scope.recovered = false;
        scope.retryPage = () => {
          if (scope.loading || !scope.hasMore) return;
          scope.error = false;
          scope.loading = true;
          return new Promise(resolve => setTimeout(() => {
            scope.recoveredOrders.push(
              { id: 1040, label: 'Order #1040' },
              { id: 1039, label: 'Order #1039' },
            );
            scope.recovered = true;
            scope.hasMore = false;
            scope.loading = false;
            resolve();
          }, 260));
        };
      });
    """


preview = InfiniteScrollErrorRetry()
preview  # noqa: B018

Use named form actions

The observer never submits a form. A named action remains explicit and useful without a browser runtime.

Submit a real Load more action
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class InfiniteScrollServerAction(Component):
    template = """
      <form x-data @submit.prevent="loadServerPage($event)">
        <label>Search query <input name="query" required /></label>
        <c-CInfiniteScroll
          aria_label="Server search results"
          action_name="result_action"
          action_value="next:2"
          c-auto="False"
          $c-props="{loading, hasMore}"
        >
          <ol>
            <li>Camera body comparison</li>
            <li>Lens mount guide</li>
            <template x-for="result in moreResults" :key="result.id">
              <li x-text="result.label"></li>
            </template>
          </ol>
        </c-CInfiniteScroll>
        <output aria-live="polite" x-text="acceptedAction">Waiting for a named action</output>
      </form>
    """

    js = """
      $component(({ scope }) => {
        scope.moreResults = [];
        scope.loading = false;
        scope.hasMore = true;
        scope.acceptedAction = 'Waiting for a named action';
        scope.loadServerPage = event => {
          const submitter = event.submitter;
          if (!submitter || scope.loading || !scope.hasMore) return;
          scope.acceptedAction = `${submitter.name}=${submitter.value}`;
          scope.loading = true;
          setTimeout(() => {
            scope.moreResults.push(
              { id: 3, label: 'Mirrorless travel kit' },
              { id: 4, label: 'Low-light autofocus test' },
            );
            scope.hasMore = false;
            scope.loading = false;
          }, 240);
        };
      });
    """


preview = InfiniteScrollServerAction()
preview  # noqa: B018

The preview reports the accepted submitter name and value, then appends its dummy response without leaving the frame. A production form sends that named action to its application endpoint.

Announce bounded state

Give a mixed page an aria_label. Loading, error, and end messages use a polite status. The sentinel is hidden from assistive technology and the button stays keyboard reachable.

Name and finish a result feed
Show code
import citry_ui
from citry import Component, citry

citry.register_library(citry_ui)


class InfiniteScrollAccessibility(Component):
    template = """
      <c-CInfiniteScroll aria_label="Completed notifications" c-has_more="False">
        <ul><li>Backup completed</li><li>Invoice sent</li></ul>
      </c-CInfiniteScroll>
    """


preview = InfiniteScrollAccessibility()
preview  # noqa: B018

The five default strings are Citry UI messages. Explicit label inputs opt that one output out of catalog-driven browser updates.

API reference

Inputs

CInfiniteScroll server inputs

Server inputs are passed in a template through <c-CInfiniteScroll ... /> or in Python through CInfiniteScroll(...).

InputTypeDefaultEffect
idstr | NonegeneratedSets the root ID.
aria_labelstr | NoneNoneNames the root and gives it region semantics.
has_moreboolTrueControls whether another page exists.
loadingboolFalseShows pending state and suppresses requests.
errorboolFalseShows error state while changing the action to Retry and pausing automatic observation.
disabledboolFalseDisables requests and the action.
autoboolTrueEnables Intersection Observer requests when a callback exists and no loading error disabled or end state blocks them.
root_marginstr"0px 0px 240px 0px"Sets the observer prefetch margin.
thresholdfloat0Sets a finite observer threshold from zero through one.
action_namestr | NoneNoneMakes the action a named submit button when supplied.
action_valuestr"load-more"Sets the submit button value.
load_more_labelstr"Load more"Overrides Load more text.
retry_labelstr"Try again"Overrides Retry text.
loading_labelstr"Loading more results"Overrides pending status text.
error_labelstr"More results could not be loaded"Overrides error status text.
end_labelstr"No more results"Overrides end status text.
class_CClassValue | None (CClassValue)NoneAdds root classes.
styleCStyleValue | None (CStyleValue)NoneAdds root styles.
attrsMapping[str, object] | NoneNoneAdds copied allowed root attributes.

CInfiniteScroll client inputs

Client inputs are passed in the browser through the $c-props="{ ... }" attribute on <c-CInfiniteScroll />.

InputTypeOmitted behaviorEffect
hasMorebooleanUses the server value.Reactively controls whether another page exists.
loadingbooleanUses the server value.Reactively controls pending state.
errorbooleanUses the server value.Reactively controls retry state.
disabledbooleanUses the server value.Reactively disables requests.
autobooleanUses the server value.Reactively enables observation.
onLoadMorefunctionThe observer stays inactive and the native button remains available.Receives each load request and may return a Promise.

Slots

Slots are passed as nested content or <c-fill> tags in a template, or through the slots={...} argument in Python.

CInfiniteScroll slots

SlotRequiredDataFallback
defaultno{} (CInfiniteScrollDefaultSlotData)Empty result content.

Events

Component events are callback inputs supplied through $c-props. Native browser events remain available through Alpine @... attributes.

CInfiniteScroll events

EventSignatureTrigger and timingDetailControlled and cancellation behavior
onLoadMore(detail: CInfiniteScrollLoadDetail) => void | Promise<void> (CInfiniteScrollLoadDetail)An enabled action is activated or its observed sentinel intersects.{reason, sourceEvent} (CInfiniteScrollLoadDetail)Requests data without mutating result content or submitting from the observer.

Methods

-

CSS

CSS variables to theme the components. Set them on an ancestor or the component itself.

CInfiniteScroll CSS variables

Apply these variables to CInfiniteScroll or one of its ancestors.

VariableTypePurposeDefault
--cui-infinite-scroll-gaplengthGap among content status action and sentinel.0.875rem
--cui-infinite-scroll-action-bordercomplete borderAction boundary.Adaptive 1px neutral
--cui-infinite-scroll-action-surfacecolorAction surface.Canvas
--cui-infinite-scroll-action-radiuslengthAction corners.0.625rem
--cui-infinite-scroll-focuscolorAction focus ring.Highlight
--cui-infinite-scroll-mutedcolorStatus text.Adaptive muted CanvasText

Attributes

HTML attributes defined on the components that you can refer to for CSS, inspection, and testing. Read-only.

CInfiniteScroll attributes

AttributeElementTypeMeaning
aria-busyContenttrue | falseReflects loading without delaying sibling status announcements.
data-loadingRootpresent | absentReflects loading.
data-errorRootpresent | absentReflects visible retry state.
data-endRootpresent | absentReflects exhausted results.
data-disabledRootpresent | absentReflects disabled requests.
data-autoRootpresent | absentReflects observation preference.

Selectors

Selectors for the DOM nodes in the components that you can use for CSS, inspection, and testing.

CInfiniteScroll selectors

SelectorElementPurpose
[data-citry-ui-part="infinite-scroll"]RootRequest boundary and state destination.
[data-citry-ui-part="content"]Content divServer-owned results.
[data-citry-ui-part="status"]Polite statusPending error and end announcements.
[data-citry-ui-part="action"]Native buttonExplicit Load more or Retry path.
[data-citry-ui-part="sentinel"]Hidden spanIntersection observation target.

Interfaces

Aliases and data shapes referenced above.

Input type aliases

InterfaceDefinition
CInfiniteScrollReasonLiteral["button", "intersection", "retry"]
CClassValuestr | Mapping[str, bool] | Sequence[CClassValue]
CStyleValuestr | Mapping[str, object] | Sequence[CStyleValue]

CInfiniteScrollDefaultSlotData

Empty dataclass: {}.

CInfiniteScrollLoadDetail

FieldTypeDefaultMeaning
reasonCInfiniteScrollReason (CInfiniteScrollReason)-Button intersection or retry request source.
sourceEventobject | None-Native click Event or null for intersection.

Translation keys

Catalog keys used by this family. An explicit component input or slot listed in Override takes precedence over the catalog for that instance.

CInfiniteScroll translation keys

KeyPurposeVariablesOverrideBrowser updates
citry-ui-infinite-scroll-load-moreLabels the ordinary load action.None.load_more_labelStable $c-tr text.
citry-ui-infinite-scroll-retryLabels the retry action.None.retry_labelStable $c-tr text.
citry-ui-infinite-scroll-loadingAnnounces a pending request.None.loading_labelStable $c-tr text.
citry-ui-infinite-scroll-errorAnnounces a failed request.None.error_labelStable $c-tr text.
citry-ui-infinite-scroll-endAnnounces exhausted results.None.end_labelStable $c-tr text.