UnflowUI
Components

List

A generic, accessible list that owns keyboard navigation, focus and virtualization, delegating row rendering to a `renderItem` callback — pair it with ListItem for ready-made rows.

Overview

List and ListItem are designed to be used together:

  • List owns everything about how the list behaves: keyboard navigation (roving tabindex or active-descendant), focus management, selection semantics (listbox/menu roles, aria-multiselectable) and — via a swappable list slot — virtualization. It never renders a row itself; it calls your renderItem(item, state) for every item and hands you back navigation props (state.itemProps) plus an activate() helper to spread onto whatever you render.
  • ListItem is the ready-made row: label/description text, an optional leading element, and either a checkbox variant (for multiselect) or a mark variant (a check icon shown when selected). You spread state.itemProps onto it so List's keyboard handling and focus tracking work.

You can use List without ListItem (render anything from renderItem), and ListItem is a plain presentational component you could reuse outside List — but the two are built to compose: List drives focus/selection, ListItem visualizes it.

Import

import { List } from '@unflow.io/ui/components/List';
import { ListItem } from '@unflow.io/ui/components/ListItem';

List

Props

PropTypeDefaultDescription
itemsArray<T & { onClick?: () => void }>Required. Items to render. Each item may carry its own onClick, fired in addition to the list's onClick
renderItem(item: T, state: ListItemState) => ReactNodeRequired. Renders a row. state gives you index, focused, itemProps (spread on the focusable element) and activate() (focuses the row and fires selection)
onClick(item: T, index: number) => voidCalled when a row is activated — via click, or Enter/Space while it has keyboard focus
multiselectbooleanSets aria-multiselectable on the container. List doesn't track selection itself — drive ListItem's selected from your own state
virtualizedbooleanfalseSwitches to the active-descendant model (DOM focus stays on the container, the active row is tracked via aria-activedescendant). Pair with a list slot that only mounts visible rows
rowHeightnumber36Row height in px. Used for the container's max-height cap and passed to the list slot for virtualization math
maxVisibleRowsnumber6 when virtualized, otherwise unsetCaps the container to N visible rows (maxHeight = maxVisibleRows * rowHeight); the list scrolls beyond that
role'listbox' | 'menu''listbox'ARIA role of the container
getItemKey(item: T, index: number) => KeyindexReact key for each row
refRef<ListHandle>Exposes focusItem(index, align?) to programmatically move focus (and scroll, when virtualized)

ListItem

Props

PropTypeDefaultDescription
labelstringPrimary text
descriptionstringSecondary text shown below the label
startElReactNodeElement rendered before the label/description (icon, avatar, etc.)
selectedbooleanfalseMarks the row as selected — sets aria-selected, checks the checkbox (variant="checkbox") or shows the check icon (variant="mark")
focusedbooleanVisual focus indicator, exposed as data-focused — typically wired from ListItemState.focused
variant'checkbox' | 'mark''mark'checkbox renders a Checkbox before the content (multiselect UI); mark shows a check icon after the content when selected
ariaAttributesRecord<string, string | number | boolean>Extra aria-* attributes spread onto the <li>
indexnumberRow index (informational, not used for behavior)

ListItem also accepts standard <li> props (onClick, className, children, …) via BaseListItemProps<HTMLLIElement>. Passing children overrides the default startEl/label/description layout entirely.

Basic list

  • Apple

    A tasty apple

  • Banana

    A tasty banana

  • Cherry

    A tasty cherry

  • Dragonfruit

    A tasty dragonfruit

  • Elderberry

    A tasty elderberry

'use client';
import { useState } from 'react';
import { List } from '@unflow.io/ui/components/List';
import { ListItem } from '@unflow.io/ui/components/ListItem';

type Fruit = { id: number; label: string; description: string };

const fruits: Fruit[] = ['Apple', 'Banana', 'Cherry', 'Dragonfruit', 'Elderberry'].map(
  (label, id) => ({ id, label, description: `A tasty ${label.toLowerCase()}` }),
);

export function Example() {
  const [selectedId, setSelectedId] = useState<number | null>(null);

  return (
    <List
      items={fruits}
      getItemKey={(item) => item.id}
      onClick={(item) => setSelectedId(item.id)}
      renderItem={(item, { focused, itemProps, activate }) => (
        <ListItem
          {...itemProps}
          onClick={activate}
          label={item.label}
          description={item.description}
          selected={selectedId === item.id}
          focused={focused}
        />
      )}
    />
  );
}

Arrow keys move focus between rows; Enter/Space (or a click) calls activate(), which focuses the row and fires both renderItem's activate chain and List's onClick.

Set role="menu" for an action list rather than a selectable list — there's no selected state, each item just runs its own onClick.

Last action:

'use client';
import { useState } from 'react';
import { List } from '@unflow.io/ui/components/List';
import { ListItem } from '@unflow.io/ui/components/ListItem';

type Action = { id: string; label: string; onClick: () => void };

export function Example() {
  const [lastAction, setLastAction] = useState('—');

  const actions: Action[] = [
    { id: 'edit', label: 'Edit', onClick: () => setLastAction('Edit') },
    { id: 'duplicate', label: 'Duplicate', onClick: () => setLastAction('Duplicate') },
    { id: 'share', label: 'Share', onClick: () => setLastAction('Share') },
    { id: 'delete', label: 'Delete', onClick: () => setLastAction('Delete') },
  ];

  return (
    <List
      role="menu"
      items={actions}
      getItemKey={(item) => item.id}
      renderItem={(item, { focused, itemProps, activate }) => (
        <ListItem {...itemProps} onClick={activate} label={item.label} focused={focused} />
      )}
    />
  );
}

Each item's own onClick (set on the item object) fires when it's activated — List calls it before the list-level onClick.

Multiselect with checkboxes

Set multiselect on List and variant="checkbox" on ListItem, then track selected ids yourself and toggle them in onClick.

  • Apple

  • Banana

  • Cherry

  • Dragonfruit

  • Elderberry

'use client';
import { useState } from 'react';
import { List } from '@unflow.io/ui/components/List';
import { ListItem } from '@unflow.io/ui/components/ListItem';

type Fruit = { id: number; label: string };

const fruits: Fruit[] = ['Apple', 'Banana', 'Cherry', 'Dragonfruit', 'Elderberry'].map(
  (label, id) => ({ id, label }),
);

export function Example() {
  const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());

  const toggle = (id: number) =>
    setSelectedIds((prev) => {
      const next = new Set(prev);
      next.has(id) ? next.delete(id) : next.add(id);
      return next;
    });

  return (
    <List
      multiselect
      items={fruits}
      getItemKey={(item) => item.id}
      onClick={(item) => toggle(item.id)}
      renderItem={(item, { focused, itemProps, activate }) => (
        <ListItem
          {...itemProps}
          onClick={activate}
          variant="checkbox"
          label={item.label}
          selected={selectedIds.has(item.id)}
          focused={focused}
        />
      )}
    />
  );
}

Virtualization

virtualized on its own only changes the focus model (active-descendant instead of roving tabindex). To actually window the rows you provide a list slot — a component that receives ListViewProps and is responsible for rendering only the visible rows (e.g. via react-window or @tanstack/react-virtual), and for reporting back to List's navigation core:

import { List, type ListViewProps } from '@unflow.io/ui/components/List';

function MyVirtualListView({
  itemCount,
  rowHeight,
  maxVisibleRows,
  renderItem,
  containerProps,
  registerScrollTo,
  reportVisibleRange,
}: ListViewProps) {
  // 1. Render only the rows currently in view using `renderItem(index)`.
  // 2. Call `registerScrollTo` with a function that scrolls to a given index —
  //    List calls this internally when arrow keys move focus off-screen.
  // 3. Call `reportVisibleRange({ start, stop })` whenever the visible window
  //    changes, so arrow-key navigation can re-enter at the right edge.
  // ...
}

<List
  virtualized
  rowHeight={48}
  maxVisibleRows={6}
  items={manyItems}
  slots={{ list: MyVirtualListView }}
  renderItem={(item, { focused, itemProps, activate }) => (
    <ListItem {...itemProps} onClick={activate} label={item.label} focused={focused} />
  )}
/>;

Without a list slot, virtualized still keeps every row mounted (like the default view) — only the focus model changes, so it's mainly useful once you've wired in a real windowing library.

Customization (slots & slotProps)

List

SlotTypeDescription
listComponentType<ListViewProps>Renders the scroll container. Defaults to a plain <ul> with roving tabindex; provide one together with virtualized to plug in a virtualization library
Slot PropPropsDescription
listBaseHTMLProps<HTMLUListElement>Props merged onto the default (non-virtualized) <ul> container

ListItem

SlotTypeDescription
checkbox(checked: boolean) => ReactNodeOverrides the checkbox rendered when variant="checkbox"
checkIconReactNodeOverrides the check icon shown when variant="mark" and selected
Slot PropPropsDescription
startElRootBaseHTMLProps<HTMLDivElement>Wrapper around startEl
contentRootBaseHTMLProps<HTMLDivElement>Wrapper around the label/description block
labelBaseHTMLProps<HTMLParagraphElement>The label <p>
descriptionBaseHTMLProps<HTMLParagraphElement>The description <p>
checkIconRootBaseHTMLProps<HTMLDivElement>Wrapper around the check icon (variant="mark")
checkboxOmit<ICheckbox, 'checked'>Props for the default checkbox (variant="checkbox") — checked is controlled by selected

Accessibility

  • List drives keyboard interaction itself: ArrowUp/ArrowDown move focus between rows (wrapping at the ends), Enter/Space activate the focused row.
  • Non-virtualized lists use roving tabindex — only the focused row has tabIndex={0}, the rest are -1 and DOM focus genuinely moves between rows.
  • Virtualized lists use the active-descendant pattern instead — DOM focus stays on the container (tabIndex={0}), and the active row is exposed via aria-activedescendant, since a genuinely focused row can be unmounted when scrolled out of view.
  • role="listbox" + multiselect sets aria-multiselectable on the container; each ListItem sets aria-selected from its selected prop, and aria-label/aria-description derived from label/description (or your own overrides).
  • ListItem's focused prop only drives a data-focused attribute for styling — actual focus/selection state and semantics come from List.