Skip to content

cv-feed ​

Bidirectional infinite-scrolling feed container that dynamically loads articles as the user scrolls, with APG-compliant keyboard navigation and focus management.

Headless: createFeed

Cross-Spec Consistency ​

This document is the UIKit surface contract for Feed.

  • Headless createFeed is the source of truth for state, transitions, and invariants.
  • UIKit mirrors headless contracts through DOM attributes and events.
  • Any intentional divergence between UIKit and headless MUST be documented in both specs.

Usage ​

View source
html
<div class="feed-demo-shell" data-demo="feed" data-live-demo-height="780">
  <section class="feed-demo-hero" aria-labelledby="feed-demo-title">
    <div class="feed-demo-copy">
      <span class="feed-demo-kicker">Bidirectional stream</span>
      <h3 id="feed-demo-title">Feed emits load requests; consumers slot in new articles.</h3>
      <p>
        This is not virtualization. Scroll sentinels request more records, then the wrapper appends or
        prepends <code>cv-feed-article</code> nodes while the feed exposes a busy update state.
      </p>
    </div>

    <dl class="feed-demo-status" aria-label="Feed runtime state">
      <div>
        <dt>Loaded</dt>
        <dd data-feed-loaded>3 articles</dd>
      </div>
      <div>
        <dt>Active</dt>
        <dd data-feed-active>Latest device handoff</dd>
      </div>
      <div>
        <dt>Busy</dt>
        <dd data-feed-busy>false</dd>
      </div>
      <div>
        <dt>Last event</dt>
        <dd data-feed-last>Ready</dd>
      </div>
    </dl>
  </section>

  <section class="feed-demo-board" aria-label="Interactive feed demo">
    <div class="feed-demo-toolbar" aria-label="Feed demo controls">
      <cv-button data-feed-action="newer" variant="primary">Load newer</cv-button>
      <cv-button data-feed-action="older">Load older</cv-button>
      <cv-button data-feed-action="error" variant="danger">Trigger error</cv-button>
      <cv-button data-feed-action="clear">Clear error</cv-button>
      <cv-button data-feed-action="empty">Empty</cv-button>
      <cv-button data-feed-action="reset">Reset</cv-button>
    </div>

    <div class="feed-demo-main">
      <div class="feed-demo-frame">
        <button type="button" class="feed-demo-boundary" data-feed-before>Focus target before feed</button>

        <div class="feed-demo-scroll-wrap">
          <div class="feed-demo-scroll" data-feed-scroll>
            <cv-feed class="feed-demo-feed" label="Vault activity stream">
              <cv-empty-state
                slot="empty"
                headline="No activity loaded"
                description="Use Reset or either load control to repopulate the stream."
              ></cv-empty-state>
              <cv-callout slot="error" variant="danger">
                Feed failed to load. Clear the error to continue.
              </cv-callout>
            </cv-feed>
          </div>

          <div class="feed-demo-load-status" data-feed-load-status aria-live="polite" hidden>
            <cv-spinner></cv-spinner>
            <span>Loading articles...</span>
          </div>
        </div>

        <button type="button" class="feed-demo-boundary" data-feed-after>Focus target after feed</button>
      </div>

      <aside class="feed-demo-log" aria-label="Feed event log">
        <span class="feed-demo-label">Event log</span>
        <ol data-feed-events aria-live="polite"></ol>
      </aside>
    </div>
  </section>
</div>

<script>
  document.querySelectorAll('.feed-demo-shell[data-demo="feed"]:not([data-ready])').forEach((shell) => {
    shell.dataset.ready = 'true'

    const feed = shell.querySelector('.feed-demo-feed')
    const scroller = shell.querySelector('[data-feed-scroll]')
    const beforeTarget = shell.querySelector('[data-feed-before]')
    const afterTarget = shell.querySelector('[data-feed-after]')
    const loadedOutput = shell.querySelector('[data-feed-loaded]')
    const activeOutput = shell.querySelector('[data-feed-active]')
    const busyOutput = shell.querySelector('[data-feed-busy]')
    const lastOutput = shell.querySelector('[data-feed-last]')
    const loadStatus = shell.querySelector('[data-feed-load-status]')
    const loadStatusText = loadStatus?.querySelector('span')
    const eventList = shell.querySelector('[data-feed-events]')

    if (
      !feed ||
      !scroller ||
      !loadedOutput ||
      !activeOutput ||
      !busyOutput ||
      !lastOutput ||
      !loadStatus ||
      !loadStatusText ||
      !eventList
    ) {
      return
    }

    const initialRecords = [
      {
        id: 'activity-01',
        title: 'Latest device handoff',
        meta: 'aria-posinset 1',
        body: 'Desktop approved an Android USB bridge session.',
      },
      {
        id: 'activity-02',
        title: 'Muted recovery reminder',
        meta: 'disabled article',
        body: 'Disabled items stay in the set but are skipped by PageDown.',
        disabled: true,
      },
      {
        id: 'activity-03',
        title: 'Encrypted archive opened',
        meta: 'keyboard target',
        body: 'Focus an article, then use PageDown or PageUp to move through the stream.',
      },
    ]
    const newerSeed = [
      {
        id: 'activity-new-02',
        title: 'Fresh sync proof received',
        meta: 'prepended',
        body: 'Newer entries are inserted above the current content.',
      },
      {
        id: 'activity-new-01',
        title: 'Policy changed on phone',
        meta: 'prepended',
        body: 'The focused article contract stays stable as positions are recalculated.',
      },
    ]
    const olderSeed = [
      {
        id: 'activity-old-01',
        title: 'Archive exported',
        meta: 'appended',
        body: 'Older entries are appended when the bottom sentinel or control requests more.',
      },
      {
        id: 'activity-old-02',
        title: 'Vault note imported',
        meta: 'appended',
        body: 'The feed root reports aria-busy while the wrapper simulates loading.',
      },
      {
        id: 'activity-old-03',
        title: 'Pairing challenge reviewed',
        meta: 'appended',
        body: 'All article positions update from the same headless contract.',
      },
    ]

    let newerQueue = []
    let olderQueue = []
    let sentinelLoadArmed = false
    let loadingDirection = null

    const cloneRecords = (records) => records.map((record) => ({...record}))

    const getArticleElements = () => [...feed.querySelectorAll('cv-feed-article')]

    const clearArticles = () => {
      getArticleElements().forEach((article) => article.remove())
    }

    const createArticle = (record) => {
      const article = document.createElement('cv-feed-article')
      article.articleId = record.id
      article.disabled = record.disabled === true
      article.innerHTML = `
        <article class="feed-demo-article">
          <div>
            <h4>${record.title}</h4>
            <p>${record.body}</p>
          </div>
          <span>${record.meta}</span>
        </article>
      `
      return article
    }

    const insertRecords = (records, direction) => {
      const fragment = document.createDocumentFragment()
      records.forEach((record) => fragment.append(createArticle(record)))

      if (direction === 'newer') {
        feed.insertBefore(fragment, feed.querySelector('cv-feed-article'))
        return
      }

      feed.append(fragment)
    }

    const getActiveLabel = () => {
      const active = feed.querySelector('cv-feed-article[data-active="true"] h4')
      return active?.textContent?.trim() || 'none'
    }

    const addEvent = (message) => {
      const item = document.createElement('li')
      item.textContent = message
      lastOutput.textContent = message
      eventList.prepend(item)

      while (eventList.children.length > 5) {
        eventList.lastElementChild?.remove()
      }
    }

    const syncStatus = () => {
      const count = getArticleElements().length
      const busy = feed.busy
        ? 'true'
        : feed.shadowRoot?.querySelector('[part="base"]')?.getAttribute('aria-busy') || 'false'

      loadedOutput.textContent = `${count} ${count === 1 ? 'article' : 'articles'}`
      activeOutput.textContent = getActiveLabel()
      busyOutput.textContent = busy
    }

    const scheduleStatusSync = () => {
      requestAnimationFrame(syncStatus)
    }

    const setBusy = (direction, value) => {
      feed.busy = value
      scroller.toggleAttribute('data-loading', value)
      loadStatus.hidden = !value
      loadStatusText.textContent =
        direction === 'newer' ? 'Prepending newer records...' : 'Appending older records...'
      scheduleStatusSync()
    }

    const loadRecords = async (direction, source) => {
      if (loadingDirection) {
        addEvent(`Ignored ${source}; ${loadingDirection} load is already running.`)
        return
      }

      const queue = direction === 'newer' ? newerQueue : olderQueue
      const batch = queue.splice(0, 2)

      if (batch.length === 0) {
        addEvent(`No ${direction === 'newer' ? 'newer' : 'older'} records left.`)
        return
      }

      loadingDirection = direction
      feed.error = false
      setBusy(direction, true)
      addEvent(`${source}: loading ${batch.length} ${direction} records.`)
      const previousScrollHeight = scroller.scrollHeight
      const previousScrollTop = scroller.scrollTop
      await new Promise((resolve) => setTimeout(resolve, 420))
      insertRecords(batch, direction)
      setBusy(direction, false)
      loadingDirection = null
      scheduleStatusSync()

      if (direction === 'newer' && source.startsWith('cv-load')) {
        requestAnimationFrame(() => {
          scroller.scrollTop = previousScrollTop + (scroller.scrollHeight - previousScrollHeight)
        })
      }

      if (direction === 'newer' && source === 'control') {
        requestAnimationFrame(() => {
          scroller.scrollTop = 0
        })
      }
    }

    const armSentinelLoading = () => {
      if (loadingDirection || sentinelLoadArmed) return
      sentinelLoadArmed = true
      addEvent('Sentinels armed for scroll-triggered loading.')
    }

    const resetFeed = () => {
      newerQueue = cloneRecords(newerSeed)
      olderQueue = cloneRecords(olderSeed)
      sentinelLoadArmed = false
      loadingDirection = null
      feed.error = false
      feed.loading = false
      feed.busy = false
      loadStatus.hidden = true
      scroller.removeAttribute('data-loading')
      clearArticles()
      insertRecords(cloneRecords(initialRecords), 'older')
      addEvent('Reset loaded the initial feed records.')
      scheduleStatusSync()
      requestAnimationFrame(() => {
        scroller.scrollTop = 0
      })
    }

    feed.addEventListener('cv-load-more', () => {
      if (!sentinelLoadArmed) return
      sentinelLoadArmed = false
      void loadRecords('older', 'cv-load-more')
    })

    feed.addEventListener('cv-load-newer', () => {
      if (!sentinelLoadArmed) return
      sentinelLoadArmed = false
      void loadRecords('newer', 'cv-load-newer')
    })

    feed.addEventListener('cv-exit-before', () => {
      addEvent('cv-exit-before moved focus before the feed.')
      beforeTarget?.focus()
    })

    feed.addEventListener('cv-exit-after', () => {
      addEvent('cv-exit-after moved focus after the feed.')
      afterTarget?.focus()
    })

    feed.addEventListener('keydown', (event) => {
      if (event.key === 'PageDown' || event.key === 'PageUp') {
        requestAnimationFrame(() => {
          addEvent(`${event.key} moved active article to ${getActiveLabel()}.`)
          syncStatus()
        })
      }
    })

    shell.querySelectorAll('[data-feed-action]').forEach((control) => {
      control.addEventListener('click', () => {
        const action = control.dataset.feedAction

        if (action === 'newer') void loadRecords('newer', 'control')
        if (action === 'older') void loadRecords('older', 'control')
        if (action === 'error') {
          feed.error = true
          addEvent('Error slot rendered.')
          scheduleStatusSync()
        }
        if (action === 'clear') {
          feed.error = false
          addEvent('Error cleared.')
          scheduleStatusSync()
        }
        if (action === 'empty') {
          clearArticles()
          feed.error = false
          addEvent('Articles removed; empty slot rendered.')
          scheduleStatusSync()
        }
        if (action === 'reset') resetFeed()
      })
    })

    scroller.addEventListener('wheel', armSentinelLoading, {passive: true})
    scroller.addEventListener('touchstart', armSentinelLoading, {passive: true})
    scroller.addEventListener('pointerdown', armSentinelLoading)

    resetFeed()
    addEvent('Use the scroll area or load controls to request more articles.')
  })
</script>

Anatomy ​

<cv-feed> (host)
└── <div part="base" role="feed">
    ├── <div part="sentinel-top">                ← IntersectionObserver target for loading newer
    ├── <div part="loading-indicator" aria-hidden="true">  ← only when [loading]
    │   └── <slot name="loading">
    ├── <slot name="empty">                      ← only when [empty]
    ├── <slot name="error">                      ← only when [error]
    ├── <slot>                                   ← accepts <cv-feed-article> children
    └── <div part="sentinel-bottom">             ← IntersectionObserver target for loading more

Attributes ​

AttributeTypeDefaultDescription
labelString""Accessible name for the feed (aria-label)
busyBooleanfalseReflects aria-busy during load operations
loadingBooleanfalseShows loading indicator
emptyBooleanfalseIndicates no articles are loaded (read-only, reflected from headless)
errorBooleanfalseIndicates an error state is present (read-only, reflected from headless)

Slots ​

SlotDescription
(default)One or more <cv-feed-article> children
emptyContent shown when the feed has no articles
errorContent shown when the feed is in an error state
loadingCustom loading indicator content

CSS Parts ​

PartElementDescription
base<div>Root wrapper with role="feed"
sentinel-top<div>Top intersection sentinel for loading newer content
sentinel-bottom<div>Bottom intersection sentinel for loading more content
empty<slot>Empty state slot wrapper
error<slot>Error state slot wrapper
loading-indicator<div>Loading indicator wrapper

CSS Custom Properties ​

PropertyDefaultDescription
--cv-feed-gapvar(--cv-space-3, 12px)Spacing between articles
--cv-feed-padding-blockvar(--cv-space-3, 12px)Vertical padding of the feed container
--cv-feed-padding-inline0Horizontal padding of the feed container
--cv-feed-sentinel-height1pxHeight of sentinel elements (should remain minimal)
--cv-feed-loading-min-height48pxMinimum height of the loading indicator area

Visual States ​

Host selectorDescription
:host([busy])Feed is busy loading content; aria-busy="true" on the feed root
:host([loading])Loading indicator is visible
:host([empty])Feed has no articles; empty slot is rendered
:host([error])Feed has an error; error slot is rendered

ARIA Contract ​

  • Root element has role="feed"
  • Root element exposes aria-label (from label attribute) and aria-busy (from busy attribute)
  • The feed container itself is NOT focusable
  • Articles are focusable via roving tabindex managed by headless
  • Ctrl+End and Ctrl+Home move focus outside the feed (adapter responsibility)

All ARIA attributes on the feed root are derived from contracts.getFeedProps(). UIKit does not compute ARIA state independently.

Events ​

EventDetailDescription
cv-load-more{}Fired when the bottom sentinel enters the viewport (IntersectionObserver)
cv-load-newer{}Fired when the top sentinel enters the viewport (IntersectionObserver)
cv-exit-after{}Fired on Ctrl+End; consumer should move focus after the feed
cv-exit-before{}Fired on Ctrl+Home; consumer should move focus before the feed

These events are output-only signals. The feed does not use input or change events because it has no user-modifiable value state.

Reactive State Mapping ​

cv-feed is a visual adapter over headless createFeed.

Attribute to Headless (UIKit -> Headless) ​

UIKit PropertyDirectionHeadless Binding
labelattr -> optionpassed as ariaLabel in createFeed(options)
busyattr -> actionactions.setBusy(value)

Headless to DOM (Headless -> UIKit) ​

Headless StateDirectionDOM Reflection
state.isBusy()state -> attr[busy] host attribute
state.isLoading()state -> attr[loading] host attribute
state.isEmpty()state -> attr[empty] host attribute
state.hasError()state -> attr[error] host attribute
state.error()state -> rendererror message available for the error slot
state.canLoadMore()state -> renderbottom sentinel visibility / observer activation
state.canLoadNewer()state -> rendertop sentinel visibility / observer activation
state.articleIds()state -> renderordered list for rendering articles
state.activeArticleId()state -> renderfocus management on child articles

Contract Spreading ​

  • contracts.getFeedProps() is spread onto [part="base"] -- applies role, aria-label, aria-busy
  • contracts.getArticleProps(articleId) is spread onto each cv-feed-article child -- applies role, tabindex, aria-posinset, aria-setsize, aria-disabled, data-active, onFocus

UIKit-Only Concerns (NOT in headless) ​

  • IntersectionObserver setup on [part="sentinel-top"] and [part="sentinel-bottom"]
  • DOM focus transfer for Ctrl+End / Ctrl+Home (dispatches cv-exit-after / cv-exit-before events)
  • Empty state and error state conditional slot rendering
  • Loading indicator rendering
  • cv-load-more and cv-load-newer event dispatch

Behavioral Contract ​

Bidirectional Loading ​

  • The bottom sentinel [part="sentinel-bottom"] is observed via IntersectionObserver. When it intersects the viewport and state.canLoadMore() is true, actions.loadMore() is called and cv-load-more is dispatched.
  • The top sentinel [part="sentinel-top"] is observed via IntersectionObserver. When it intersects the viewport and state.canLoadNewer() is true, actions.loadNewer() is called and cv-load-newer is dispatched.
  • Concurrent load operations are guarded by headless (second call is a no-op while loading).

Keyboard Navigation ​

Per W3C APG Feed Pattern:

  • PageDown: move focus to the next article (headless focusNextArticle)
  • PageUp: move focus to the previous article (headless focusPrevArticle)
  • Ctrl+End: dispatch cv-exit-after event; consumer moves focus after the feed
  • Ctrl+Home: dispatch cv-exit-before event; consumer moves focus before the feed

Keyboard events are forwarded to actions.handleKeyDown(event). The return value determines adapter behavior:

  • 'next' / 'prev': headless handled focus movement
  • 'exit-after': UIKit dispatches cv-exit-after
  • 'exit-before': UIKit dispatches cv-exit-before
  • null: key not handled, no action

Conditional Rendering ​

  • When state.isEmpty() is true, the empty named slot is rendered and the default slot is hidden.
  • When state.hasError() is true, the error named slot is rendered.
  • When state.isLoading() is true, the loading named slot / default loading indicator is rendered.
  • Empty and error states are not mutually exclusive with loading; the feed may show a loading indicator alongside an error slot.

Child Elements ​

cv-feed-article ​

Individual article within a feed. The parent cv-feed manages all ARIA attributes on this element via headless contracts.

Anatomy ​

<cv-feed-article> (host)
└── <div part="base" role="article">
    └── <slot>

Attributes ​

AttributeTypeDefaultDescription
article-idString""Required unique identifier for this article within the feed
activeBooleanfalseWhether this article is the currently focused article. Managed by parent.
disabledBooleanfalseWhether this article is disabled (skipped during keyboard navigation)

Slots ​

SlotDescription
(default)Article content

CSS Parts ​

PartElementDescription
base<div>Root wrapper with role="article"

CSS Custom Properties ​

PropertyDefaultDescription
--cv-feed-article-paddingvar(--cv-space-3, 12px)Padding inside the article
--cv-feed-article-border-radiusvar(--cv-radius-sm, 6px)Border radius of the article
--cv-feed-article-focus-ring2px solid var(--cv-color-primary, #65d7ff)Focus ring style for the active article

Visual States ​

Host selectorDescription
:host([active])Article is the currently focused/active article in the feed
:host([disabled])Article is disabled and skipped during keyboard navigation

Reactive State Mapping ​

cv-feed-article receives its ARIA props from the parent cv-feed via contracts.getArticleProps(articleId):

Contract PropDOM Reflection
rolerole="article" on [part="base"]
tabindextabindex="0" (active) or tabindex="-1" (inactive) on host
aria-posinsetPosition within the feed (1-based)
aria-setsizeTotal article count or -1 if unknown
aria-disabled"true" when article is disabled
data-active"true" or "false" reflecting active state
onFocusSets this article as active in headless state

ChromVoid UIKit documentation